perf: speed up desktop startup and unify theme-aware branding (#596)
* fix: unify startup logo and loading theme behavior Show desktop window immediately with animated splash logo Align splash/logo colors with selected app theme and default themes Keep auth loading state on full-screen logo without size jump Make project SVG icons follow active app theme Use the active theme foreground color for project icons discovered from favicons Apply server-side SVG color overrides for currentColor icons via icon request params Keep non-SVG project icons unchanged while preserving existing fallback behavior * perf: speed up desktop startup and unify loading logo visuals Desktop startup now shows UI sooner while backend boot continues in background Startup host probing uses a faster local path with safer remote fallback retries OpenChamber logo cube highlights now match splash screens consistently * fix: keep macOS traffic-light buttons in the correct position on load Stop native window title updates on macOS during app initialization Prevent title bar relayout that reset custom traffic-light positioning * fix: recover missing providers and agents after fast startup Retries provider/agent loading when connection is up but core config is still empty Prevents cold-start state where models/agents appear only after manual project switch Keeps startup responsive with throttled background recovery in app bootstrap
This commit is contained in:
committed by
GitHub
parent
037a70f114
commit
d1a41000ca
@@ -95,6 +95,10 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
||||
|
||||
function App({ apis }: AppProps) {
|
||||
const { initializeApp, isInitialized, isConnected } = useConfigStore();
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const { error, clearError, loadSessions } = useSessionStore();
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
@@ -194,6 +198,49 @@ function App({ apis }: AppProps) {
|
||||
init();
|
||||
}, [initializeApp, isVSCodeRuntime]);
|
||||
|
||||
const startupRecoveryInProgressRef = React.useRef(false);
|
||||
const startupRecoveryLastAttemptRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isVSCodeRuntime) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
if (providersCount > 0 && agentsCount > 0) {
|
||||
return;
|
||||
}
|
||||
if (startupRecoveryInProgressRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - startupRecoveryLastAttemptRef.current < 750) {
|
||||
return;
|
||||
}
|
||||
|
||||
startupRecoveryLastAttemptRef.current = now;
|
||||
startupRecoveryInProgressRef.current = true;
|
||||
|
||||
const repair = async () => {
|
||||
try {
|
||||
if (providersCount === 0) {
|
||||
await loadProviders();
|
||||
}
|
||||
if (agentsCount === 0) {
|
||||
await loadAgents();
|
||||
}
|
||||
} catch {
|
||||
// Keep UI responsive; we'll retry on next cycle.
|
||||
} finally {
|
||||
startupRecoveryInProgressRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void repair();
|
||||
}, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSwitchingDirectory) {
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
|
||||
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||
|
||||
@@ -61,12 +62,10 @@ const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing workspace…' }) => (
|
||||
<AuthShell>
|
||||
<div className="w-full max-w-sm rounded-3xl border border-border/40 bg-card/90 px-6 py-5 text-center shadow-none backdrop-blur">
|
||||
<p className="typography-ui-label text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
const LoadingScreen: React.FC = () => (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
||||
<OpenChamberLogo width={120} height={120} isAnimated />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
interface MobileSessionStatusBarProps {
|
||||
onSessionSwitch?: (sessionId: string) => void;
|
||||
@@ -606,6 +607,7 @@ function SortableProjectItem({
|
||||
onDelete,
|
||||
formatProjectLabel,
|
||||
}: SortableProjectItemProps) {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -623,7 +625,12 @@ function SortableProjectItem({
|
||||
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null;
|
||||
const projectIconImageUrl = !imageFailed
|
||||
? getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
|
||||
return (
|
||||
@@ -852,9 +859,15 @@ function ProjectButton({
|
||||
onOpenEditPanel,
|
||||
formatProjectLabel,
|
||||
}: ProjectButtonProps) {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null;
|
||||
const projectIconImageUrl = !imageFailed
|
||||
? getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
setImageFailed(false);
|
||||
@@ -1415,6 +1428,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
onSessionSwitch,
|
||||
cornerRadius,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
||||
@@ -1454,7 +1468,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
const activeProject = getActiveProject();
|
||||
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
||||
const currentProjectIcon = activeProject?.icon;
|
||||
const currentProjectIconImageUrl = activeProject ? getProjectIconImageUrl(activeProject) : null;
|
||||
const currentProjectIconImageUrl = activeProject
|
||||
? getProjectIconImageUrl(activeProject, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
const currentProjectIconBackground = activeProject?.iconBackground ?? null;
|
||||
const currentProjectColor = activeProject?.color;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirect
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
@@ -257,10 +258,16 @@ const ProjectTile: React.FC<{
|
||||
onEdit: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !iconImageFailed ? getProjectIconImageUrl(project) : null;
|
||||
const projectIconImageUrl = !iconImageFailed
|
||||
? getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
const showStreamingDots = hasStreaming;
|
||||
const showAttentionDots = !hasStreaming && hasUnread;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
interface ProjectEditDialogProps {
|
||||
open: boolean;
|
||||
@@ -53,6 +54,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
||||
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
|
||||
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
||||
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [name, setName] = React.useState(projectName);
|
||||
const [icon, setIcon] = React.useState<string | null>(initialIcon);
|
||||
const [color, setColor] = React.useState<string | null>(initialColor);
|
||||
@@ -145,7 +147,13 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
||||
? (hasPendingUploadImageIcon
|
||||
? pendingUploadIconPreviewUrl
|
||||
: (hasStoredImageIcon && !pendingRemoveImageIcon
|
||||
? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
|
||||
? getProjectIconImageUrl(
|
||||
{ id: projectId, iconImage: currentIconImage ?? null },
|
||||
{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
},
|
||||
)
|
||||
: null))
|
||||
: null;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProje
|
||||
import { RiCloseLine } from '@remixicon/react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
@@ -19,6 +20,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
const selectedProject = React.useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
@@ -159,7 +161,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
? (hasPendingUploadImageIcon
|
||||
? pendingUploadIconPreviewUrl
|
||||
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
|
||||
? getProjectIconImageUrl(selectedProject)
|
||||
? getProjectIconImageUrl(selectedProject, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null))
|
||||
: null;
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import { RiAddLine, RiFolderLine } from '@remixicon/react';
|
||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const addProject = useProjectsStore((state) => state.addProject);
|
||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
@@ -91,7 +93,12 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
const selected = project.id === selectedId;
|
||||
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
|
||||
const imageUrl = brokenIconIds.has(imageFailureKey) ? null : getProjectIconImageUrl(project);
|
||||
const imageUrl = brokenIconIds.has(imageFailureKey)
|
||||
? null
|
||||
: getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
});
|
||||
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
const icon = imageUrl
|
||||
? (
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
const LEFT_FACE_CELL_OPACITIES = [
|
||||
0.2, 0.45, 0.15, 0.55,
|
||||
0.35, 0.1, 0.5, 0.25,
|
||||
0.4, 0.3, 0.45, 0.15,
|
||||
0.55, 0.2, 0.35, 0.1,
|
||||
];
|
||||
|
||||
const RIGHT_FACE_CELL_OPACITIES = [
|
||||
0.3, 0.15, 0.45, 0.25,
|
||||
0.5, 0.35, 0.1, 0.4,
|
||||
0.2, 0.55, 0.3, 0.15,
|
||||
0.45, 0.25, 0.4, 0.2,
|
||||
];
|
||||
|
||||
interface OpenChamberLogoProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
@@ -79,6 +93,9 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
}
|
||||
|
||||
const strokeColor = useMemo(() => {
|
||||
if (themeContext) {
|
||||
return themeContext.currentTheme.colors.surface.foreground;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-stroke').trim();
|
||||
if (fromVars) {
|
||||
@@ -86,9 +103,22 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
}
|
||||
}
|
||||
return isDark ? 'white' : 'black';
|
||||
}, [isDark]);
|
||||
}, [themeContext, isDark]);
|
||||
|
||||
const supportsColorMix = useMemo(() => {
|
||||
if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return CSS.supports('color', 'color-mix(in srgb, white 50%, transparent)');
|
||||
}, []);
|
||||
|
||||
const fillColor = useMemo(() => {
|
||||
if (themeContext) {
|
||||
if (supportsColorMix) {
|
||||
return `color-mix(in srgb, ${strokeColor} 15%, transparent)`;
|
||||
}
|
||||
return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-face-fill').trim();
|
||||
if (fromVars) {
|
||||
@@ -96,9 +126,15 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
}
|
||||
}
|
||||
return isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
|
||||
}, [isDark]);
|
||||
}, [themeContext, supportsColorMix, strokeColor, isDark]);
|
||||
|
||||
const cellHighlightColor = useMemo(() => {
|
||||
if (themeContext) {
|
||||
if (supportsColorMix) {
|
||||
return `color-mix(in srgb, ${strokeColor} 35%, transparent)`;
|
||||
}
|
||||
return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromVars = getComputedStyle(document.documentElement).getPropertyValue('--splash-cell-fill').trim();
|
||||
if (fromVars) {
|
||||
@@ -106,7 +142,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
}
|
||||
}
|
||||
return isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)';
|
||||
}, [isDark]);
|
||||
}, [themeContext, supportsColorMix, strokeColor, isDark]);
|
||||
|
||||
const logoFillColor = strokeColor;
|
||||
|
||||
@@ -145,15 +181,6 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
// Right face: center -> right -> bottomRight -> bottom
|
||||
const rightFaceCells = generateFaceGrid(center, right, bottomRight, bottom);
|
||||
|
||||
// Generate random opacity values for cells (stable per component instance)
|
||||
const cellOpacities = useMemo(() => {
|
||||
const opacities: number[] = [];
|
||||
for (let i = 0; i < 32; i++) { // 16 cells per face * 2 faces
|
||||
opacities.push(0.1 + Math.random() * 0.5); // Random opacity 0.1-0.6
|
||||
}
|
||||
return opacities;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
@@ -180,7 +207,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
key={`left-${i}`}
|
||||
d={cell.path}
|
||||
fill={cellHighlightColor}
|
||||
opacity={cellOpacities[i]}
|
||||
opacity={LEFT_FACE_CELL_OPACITIES[cell.row * 4 + (3 - cell.col)] ?? 0.35}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -199,7 +226,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
key={`right-${i}`}
|
||||
d={cell.path}
|
||||
fill={cellHighlightColor}
|
||||
opacity={cellOpacities[i + 16]}
|
||||
opacity={RIGHT_FACE_CELL_OPACITIES[cell.row * 4 + cell.col] ?? 0.35}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -557,14 +557,21 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
}, [applyIncomingThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||
|
||||
void updateDesktopSettings({
|
||||
themeId: currentTheme.metadata.id,
|
||||
themeVariant: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
||||
useSystemTheme: preferences.themeMode === 'system',
|
||||
lightThemeId: preferences.lightThemeId,
|
||||
darkThemeId: preferences.darkThemeId,
|
||||
splashBgLight: lightTheme.colors.surface.background,
|
||||
splashFgLight: lightTheme.colors.surface.foreground,
|
||||
splashBgDark: darkTheme.colors.surface.background,
|
||||
splashFgDark: darkTheme.colors.surface.foreground,
|
||||
});
|
||||
}, [currentTheme.metadata.id, currentTheme.metadata.variant, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]);
|
||||
}, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -113,6 +113,12 @@ export const useWindowTitle = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
if (isMac) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
await currentWindow.setTitle(title);
|
||||
} catch {
|
||||
|
||||
@@ -41,6 +41,10 @@ export type DesktopSettings = {
|
||||
themeVariant?: 'light' | 'dark';
|
||||
lightThemeId?: string;
|
||||
darkThemeId?: string;
|
||||
splashBgLight?: string;
|
||||
splashFgLight?: string;
|
||||
splashBgDark?: string;
|
||||
splashFgDark?: string;
|
||||
lastDirectory?: string;
|
||||
homeDirectory?: string;
|
||||
// Optional absolute path to `opencode` binary.
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from '@remixicon/react';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
|
||||
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
|
||||
{ key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' },
|
||||
@@ -64,10 +66,21 @@ export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
|
||||
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
|
||||
);
|
||||
|
||||
export const getProjectIconImageUrl = (project: Pick<ProjectEntry, 'id' | 'iconImage'>): string | null => {
|
||||
export const getProjectIconImageUrl = (
|
||||
project: Pick<ProjectEntry, 'id' | 'iconImage'>,
|
||||
options?: { themeVariant?: ThemeVariant; iconColor?: string },
|
||||
): string | null => {
|
||||
if (!project.iconImage || typeof project.iconImage.updatedAt !== 'number' || project.iconImage.updatedAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/projects/${encodeURIComponent(project.id)}/icon?v=${project.iconImage.updatedAt}`;
|
||||
const params = new URLSearchParams({ v: String(project.iconImage.updatedAt) });
|
||||
if (typeof options?.iconColor === 'string' && options.iconColor.trim()) {
|
||||
params.set('iconColor', options.iconColor.trim());
|
||||
}
|
||||
if (options?.themeVariant === 'light' || options?.themeVariant === 'dark') {
|
||||
params.set('theme', options.themeVariant);
|
||||
}
|
||||
|
||||
return `/api/projects/${encodeURIComponent(project.id)}/icon?${params.toString()}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user