import { useEffect, useRef, useState } from 'preact/hooks';
import { Chat } from './components/Chat';
import { Sidebar } from './components/Sidebar';
import { ThemeDialog, LanguageDialog, ModelConfigDialog, RemoteAccessDialog } from './components/SettingsDialogs';
import { RenameDialog, DeleteDialog } from './components/SessionDialogs';
import { CwdPicker } from './components/CwdPicker';
import { PermissionCard } from './components/PermissionCard';
import { resolvePendingAfterDecision } from './lib/pendingPermission';
import { getProject, listSessions, SessionMetaWithProject } from './api';
import { useT, SettingsSection } from './settings';
function cwdDisplay(cwd: string): { prefix: string; name: string } {
const clean = cwd.replace(/\/+$/, '');
const idx = clean.lastIndexOf('/');
if (idx < 0) return { prefix: '', name: cwd };
return { prefix: clean.slice(0, idx + 1), name: clean.slice(idx + 1) };
}
function readSessionIdFromUrl(): string | null {
try {
return new URLSearchParams(window.location.search).get('session');
} catch {
return null;
}
}
function shortSessionId(id: string): string {
return id.slice(0, 8);
}
export function App() {
const t = useT();
const [sessionId, setSessionId] = useState<string | null>(null);
const [activeSession, setActiveSession] = useState<SessionMetaWithProject | null>(null);
const [cwd, setCwd] = useState('');
const [pending, setPending] = useState<any | null>(null);
const [showCwd, setShowCwd] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sessionListVersion, setSessionListVersion] = useState(0);
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
const [headerMenuPos, setHeaderMenuPos] = useState<{ top: number; left: number } | null>(null);
const [headerDialog, setHeaderDialog] = useState<'rename' | 'delete' | null>(null);
const headerMenuRef = useRef<HTMLDivElement>(null);
const urlSessionRef = useRef<string | null>(readSessionIdFromUrl());
const firstUrlSync = useRef(true);
const [restoring, setRestoring] = useState<boolean>(() => readSessionIdFromUrl() != null);
useEffect(() => {
if (!headerMenuOpen) return;
const close = () => setHeaderMenuOpen(false);
const onDown = (e: MouseEvent) => {
if (headerMenuRef.current && !headerMenuRef.current.contains(e.target as Node)) {
setHeaderMenuOpen(false);
}
};
document.addEventListener('mousedown', onDown);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
return () => {
document.removeEventListener('mousedown', onDown);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
};
}, [headerMenuOpen]);
function handleSessionAssigned(id: string) {
if (id !== sessionId) {
setSessionId(id);
setSessionListVersion((v) => v + 1);
}
}
function toggleSidebar() {
setSidebarOpen((o) => !o);
}
useEffect(() => {
getProject()
.then((p) => {
if (p.working_dir) setCwd((c) => c || p.working_dir);
})
.catch(() => {
});
}, []);
useEffect(() => {
if (firstUrlSync.current) {
firstUrlSync.current = false;
return;
}
const url = new URL(window.location.href);
if (sessionId) {
url.searchParams.set('session', shortSessionId(sessionId));
} else {
url.searchParams.delete('session');
}
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
}, [sessionId]);
useEffect(() => {
const urlSid = urlSessionRef.current;
if (!urlSid) return;
let cancelled = false;
listSessions()
.then((list) => {
if (cancelled) return;
const found =
list.find((s) => s.id === urlSid) ??
list.find((s) => s.id.startsWith(urlSid));
if (found) {
setSessionId(found.id);
setActiveSession(found);
if (found.working_dir) setCwd(found.working_dir);
}
})
.catch(() => {
})
.finally(() => {
if (!cancelled) setRestoring(false);
});
return () => {
cancelled = true;
};
}, []);
function handleNewSession() {
setSessionId(null);
setActiveSession(null);
setSidebarOpen(false);
}
function handleSelectSession(session: SessionMetaWithProject) {
setSessionId(session.id);
setActiveSession(session);
if (session.working_dir) {
setCwd(session.working_dir);
}
setSidebarOpen(false);
}
function handlePickCwd(path: string) {
setCwd(path);
setSessionId(null);
setActiveSession(null);
}
function handleSessionDeleted(id: string) {
if (id === sessionId) {
setSessionId(null);
setActiveSession(null);
}
}
function handleSessionRenamed(id: string, name: string) {
if (id === sessionId) {
setActiveSession((prev) => (prev ? { ...prev, name } : prev));
}
}
const { prefix, name } = cwdDisplay(cwd);
return (
<div class="app">
{/* ===== Full-height sidebar (通栏):品牌 + 收起按钮在其顶部 ===== */}
<Sidebar
activeSessionId={sessionId}
onSelect={handleSelectSession}
onNew={handleNewSession}
open={sidebarOpen}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((c) => !c)}
onOpenSettings={(section) => setSettingsSection(section)}
reloadKey={sessionListVersion}
cwd={cwd}
onSessionRenamed={handleSessionRenamed}
onSessionDeleted={handleSessionDeleted}
/>
<div
class={'sidebar-backdrop' + (sidebarOpen ? ' show' : '')}
onClick={() => setSidebarOpen(false)}
/>
{/* ===== Main column: header (cwd breadcrumb) + chat ===== */}
<div class="main-column">
<header class="header">
<button
class="ghost-btn hamburger-btn"
onClick={toggleSidebar}
aria-label={t('header.menu')}
title={t('header.sessionList')}
>
☰
</button>
{activeSession?.name && (
<div class="header-session" ref={headerMenuRef}>
<button
class="header-session-name"
title={activeSession.name}
onClick={(e) => {
if (headerMenuOpen) {
setHeaderMenuOpen(false);
return;
}
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
setHeaderMenuPos({ top: r.bottom + 4, left: r.left });
setHeaderMenuOpen(true);
}}
>
<span class="header-session-text">{activeSession.name}</span>
<svg
class="header-session-chevron"
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{headerMenuOpen && headerMenuPos && (
<div
class="item-menu"
style={{ top: `${headerMenuPos.top}px`, left: `${headerMenuPos.left}px` }}
>
<button
class="item-menu-row"
onClick={() => {
setHeaderMenuOpen(false);
setHeaderDialog('rename');
}}
>
<span>{t('sidebar.rename')}</span>
</button>
<button
class="item-menu-row danger"
onClick={() => {
setHeaderMenuOpen(false);
setHeaderDialog('delete');
}}
>
<span>{t('sidebar.delete')}</span>
</button>
</div>
)}
</div>
)}
<span class="header-spacer" />
<button
class="cwd-breadcrumb"
onClick={() => setShowCwd(true)}
title={t('header.switchCwd')}
>
{cwd ? (
<>
<span class="cwd-prefix">{prefix}</span>
<span class="cwd-name">{name || cwd}</span>
</>
) : (
<span class="cwd-prefix">{t('header.noCwd')}</span>
)}
<span class="cwd-chevron">▾</span>
</button>
<button
class="ghost-btn header-remote-btn"
onClick={() => setSettingsSection('remote')}
aria-label={t('settings.menuRemote')}
title={t('settings.menuRemote')}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5" stroke="currentColor" stroke-width="1.2" />
<line x1="7" y1="12.3" x2="9" y2="12.3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
</header>
<div class="session-body app-sidebar">
<Chat
sessionId={sessionId}
onSessionId={handleSessionAssigned}
cwd={cwd}
onPermission={setPending}
onPermissionResolved={(callId) =>
setPending((cur: any) =>
callId === null ? null : resolvePendingAfterDecision(cur, callId),
)}
activeSession={activeSession}
restoring={restoring}
/>
</div>
</div>
{}
{showCwd && (
<CwdPicker
current={cwd}
onPick={handlePickCwd}
onClose={() => setShowCwd(false)}
/>
)}
{settingsSection === 'theme' && (
<ThemeDialog onClose={() => setSettingsSection(null)} />
)}
{settingsSection === 'language' && (
<LanguageDialog onClose={() => setSettingsSection(null)} />
)}
{settingsSection === 'model' && (
<ModelConfigDialog onClose={() => setSettingsSection(null)} />
)}
{settingsSection === 'remote' && (
<RemoteAccessDialog onClose={() => setSettingsSection(null)} />
)}
{pending && <PermissionCard req={pending} onDone={() => setPending((cur: any) => resolvePendingAfterDecision(cur, pending.call_id))} />}
{headerDialog === 'rename' && activeSession && (
<RenameDialog
session={activeSession}
onClose={() => setHeaderDialog(null)}
onDone={(name) => {
handleSessionRenamed(activeSession.id, name);
setSessionListVersion((v) => v + 1);
}}
/>
)}
{headerDialog === 'delete' && activeSession && (
<DeleteDialog
session={activeSession}
onClose={() => setHeaderDialog(null)}
onDone={() => {
handleSessionDeleted(activeSession.id);
setSessionListVersion((v) => v + 1);
}}
/>
)}
</div>
);
}