Files
openchamber/packages/ui/src/hooks/useWindowTitle.ts
T
Bohdan Triapitsyn 9032dfa5c0 fix(ui): show project names exactly as the folder is named
Auto-derived project labels were title-cased, turning .ssh into .Ssh and
opencode-claude into Opencode Claude. Show the folder name verbatim in the
sidebar, window title, settings selector and notification templates, and
migrate persisted legacy labels back to the folder name (manual renames
are preserved).
2026-08-15 10:16:31 +03:00

131 lines
3.7 KiB
TypeScript

import React from 'react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { setDesktopWindowTitle } from '@/lib/desktopNative';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
const APP_TITLE = 'OpenChamber';
const formatProjectLabel = (label: string): string => label.trim();
const getProjectNameFromPath = (path: string): string => {
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '');
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1] ?? '';
};
const buildWindowTitle = (projectLabel: string | null, instanceLabel: string | null): string => {
const parts = [projectLabel, instanceLabel, APP_TITLE].filter((part): part is string => typeof part === 'string' && part.trim().length > 0);
return parts.join(' | ');
};
export const useWindowTitle = () => {
const activeProject = useProjectsStore((state) => {
if (!state.activeProjectId) {
return null;
}
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
});
const projectLabel = React.useMemo(() => {
if (!activeProject) {
return null;
}
const label = activeProject.label?.trim();
if (label) {
return formatProjectLabel(label);
}
const pathName = getProjectNameFromPath(activeProject.path);
if (pathName) {
return formatProjectLabel(pathName);
}
return null;
}, [activeProject]);
const [instanceLabel, setInstanceLabel] = React.useState<string | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !isDesktopShell()) {
setInstanceLabel(null);
return;
}
let cancelled = false;
const refreshInstanceLabel = async () => {
try {
if (isDesktopLocalOriginActive()) {
if (!cancelled) {
setInstanceLabel(null);
}
return;
}
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
if (!cancelled) {
setInstanceLabel(null);
}
return;
}
const cfg = await desktopHostsGet();
const match = cfg.hosts.find((host) => runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false);
const nextLabel = match?.label?.trim() ? redactSensitiveUrl(match.label.trim()) : 'Instance';
if (!cancelled) {
setInstanceLabel(nextLabel);
}
} catch {
if (!cancelled) {
setInstanceLabel('Instance');
}
}
};
void refreshInstanceLabel();
const handleFocus = () => {
void refreshInstanceLabel();
};
window.addEventListener('focus', handleFocus);
return () => {
cancelled = true;
window.removeEventListener('focus', handleFocus);
};
}, []);
const title = React.useMemo(() => buildWindowTitle(projectLabel, instanceLabel), [projectLabel, instanceLabel]);
React.useEffect(() => {
if (typeof document !== 'undefined') {
document.title = title;
}
if (!isDesktopShell()) {
return;
}
const applyTitle = async () => {
try {
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
if (isMac) {
return;
}
await setDesktopWindowTitle(title);
} catch {
return;
}
};
void applyTitle();
}, [title]);
};