import { useEffect, useRef, useState } from 'preact/hooks';
import { Chat } from './components/Chat';
import { Sidebar } from './components/Sidebar';
import { ThemeDialog, LanguageDialog, ModelConfigDialog, RemoteAccessDialog, NotificationsDialog } from './components/SettingsDialogs';
import { RenameDialog, DeleteDialog } from './components/SessionDialogs';
import { CwdPicker } from './components/CwdPicker';
import { resolvePendingAfterDecision } from './lib/pendingPermission';
import { getProject, getConfig, changeDir, resolveSession, createSession, getSession, SessionMetaWithProject } from './api';
import { useT, SettingsSection } from './settings';
import { applyConfigDefaults, initNotificationPermissionPrompt } from './lib/notifications';
import { sessionMessagesToMarkdownLines } from './lib/historyMessages';
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 [optimisticSession, setOptimisticSession] = useState<SessionMetaWithProject | null>(null);
const [cwd, setCwd] = useState('');
const [projectHash, setProjectHash] = 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 [headerExporting, setHeaderExporting] = useState(false);
const headerMenuRef = useRef<HTMLDivElement>(null);
const [isLanding, setIsLanding] = useState(true);
const [skillInsert, setSkillInsert] = useState<{ name: string; seq: number } | null>(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) {
setOptimisticSession((prev) => (prev && prev.id !== id ? { ...prev, id } : prev));
if (id !== sessionId) {
setSessionId(id);
setSessionListVersion((v) => v + 1);
}
}
function handleOptimisticSession(title: string) {
const now = Math.floor(Date.now() / 1000);
setOptimisticSession({
id: activeSession?.id ?? `optimistic-${now}`,
name: title,
working_dir: activeSession?.working_dir ?? cwd,
project_hash: activeSession?.project_hash ?? projectHash,
created_at: activeSession?.created_at ?? now,
updated_at: now,
message_count: 1,
});
setActiveSession((prev) =>
prev
? {
...prev,
name: prev.name.startsWith('session-') ? title : prev.name,
}
: prev,
);
}
function handleActiveSessionMeta(s: SessionMetaWithProject) {
setActiveSession((prev) =>
prev && prev.id === s.id && prev.name !== s.name ? { ...prev, name: s.name } : prev,
);
}
function toggleSidebar() {
setSidebarOpen((o) => !o);
}
useEffect(() => {
let cancelled = false;
const disposeNotificationPrompt = initNotificationPermissionPrompt();
getConfig()
.then((cfg) => {
if (cancelled) return;
applyConfigDefaults(cfg.notifications);
})
.catch(() => {
});
getProject()
.then((p) => {
if (cancelled) return;
if (p.working_dir) setCwd((c) => c || p.working_dir);
if (p.project_hash) setProjectHash((h) => h || p.project_hash!);
})
.catch(() => {
});
return () => {
cancelled = true;
disposeNotificationPrompt();
};
}, []);
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;
resolveSession(urlSid)
.then((found) => {
if (cancelled || !found) return;
setSessionId(found.id);
setActiveSession(found);
if (found.working_dir) setCwd(found.working_dir);
if (found.project_hash) setProjectHash(found.project_hash);
})
.catch(() => {
})
.finally(() => {
if (!cancelled) setRestoring(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!sessionId) return;
if (activeSession && activeSession.id === sessionId) return;
let cancelled = false;
resolveSession(sessionId)
.then((found) => {
if (cancelled || !found) return;
setActiveSession(found);
if (found.working_dir) setCwd(found.working_dir);
if (found.project_hash) setProjectHash(found.project_hash);
setSessionListVersion((v) => v + 1);
})
.catch((err) => {
console.warn('[syncSession] resolve session failed:', err);
});
return () => {
cancelled = true;
};
}, [sessionId]);
function openNewSession(targetCwd: string | undefined) {
setSessionId(null);
setActiveSession(null);
setOptimisticSession(null);
let sync = false;
try { sync = new URLSearchParams(location.search).get('sync') === '1'; } catch { }
createSession(targetCwd || undefined, undefined, sync)
.then((data) => {
if (data.project_hash) setProjectHash(data.project_hash);
setSessionId(data.id);
setActiveSession({
id: data.id,
name: data.name,
working_dir: data.working_dir,
project_hash: data.project_hash,
created_at: data.created_at,
updated_at: data.created_at,
message_count: 0,
});
setSessionListVersion((v) => v + 1);
})
.catch((err) => {
console.warn('[newSession] create session failed:', err);
});
}
function handleNewSession() {
setSidebarOpen(false);
openNewSession(cwd || undefined);
}
function handleSelectSession(session: SessionMetaWithProject) {
setOptimisticSession(null);
setSessionId(session.id);
setActiveSession(session);
if (session.working_dir) {
setCwd(session.working_dir);
}
if (session.project_hash) setProjectHash(session.project_hash);
setSidebarOpen(false);
}
function handlePickCwd(path: string) {
setSidebarOpen(false);
setCwd(path);
setProjectHash('');
openNewSession(path || undefined);
}
function handleNewInProject(path: string) {
setSidebarOpen(false);
setCwd(path);
openNewSession(path || undefined);
}
function handleCwdChanged(newCwd: string) {
setCwd(newCwd);
getProject()
.then((p) => {
if (p.project_hash) {
setProjectHash(p.project_hash);
}
setSessionListVersion((v) => v + 1);
})
.catch((err) => {
console.warn('[cwdChanged] failed to get project state:', err);
});
}
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));
}
}
async function handleExportMarkdown() {
if (!activeSession) return;
setHeaderMenuOpen(false);
setHeaderExporting(true);
try {
const detail = await getSession(activeSession.project_hash, activeSession.id);
const title = detail.name || detail.id.slice(0, 8);
const lines = sessionMessagesToMarkdownLines(detail.messages, title);
const mdContent = lines.join('\n');
const blob = new Blob([mdContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[\\/:*?"<>|]/g, '_')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
alert(t('sidebar.exportFailed'));
} finally {
setHeaderExporting(false);
}
}
return (
<div class="app">
{/* ===== Full-height sidebar (通栏):品牌 + 收起按钮在其顶部 ===== */}
<Sidebar
activeSessionId={sessionId}
onSelect={handleSelectSession}
onNew={handleNewSession}
onNewInProject={handleNewInProject}
open={sidebarOpen}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((c) => !c)}
onOpenSettings={(section) => setSettingsSection(section)}
reloadKey={sessionListVersion}
optimisticSession={optimisticSession}
onActiveSessionMeta={handleActiveSessionMeta}
cwd={cwd}
projectHash={projectHash}
onSessionRenamed={handleSessionRenamed}
onSessionDeleted={handleSessionDeleted}
onPickSkill={(name) => setSkillInsert({ name, seq: Date.now() })}
onOpenRemote={() => setSettingsSection('remote')}
onOpenCwd={() => setShowCwd(true)}
/>
<div
class={'sidebar-backdrop' + (sidebarOpen ? ' show' : '')}
onClick={() => setSidebarOpen(false)}
/>
{/* ===== Main column: sticky session-title header + chat (no top bar) ===== */}
<div class="main-column">
{/* Mobile-only floating menu button (the old top bar carried the ☰; the
redesign has no top bar, so a fixed button gives mobile drawer access). */}
<button
class="mobile-menu-btn"
onClick={toggleSidebar}
aria-label={t('header.menu')}
title={t('header.sessionList')}
>
☰
</button>
{activeSession?.name && !isLanding && (
<header class="session-header" ref={headerMenuRef}>
<button
class="session-title-btn"
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="session-title-text">{activeSession.name}</span>
<svg
class="session-title-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"
onClick={handleExportMarkdown}
disabled={headerExporting}
>
<span>{headerExporting ? t('sidebar.exporting') : t('sidebar.exportMarkdown')}</span>
</button>
<button
class="item-menu-row danger"
onClick={() => {
setHeaderMenuOpen(false);
setHeaderDialog('delete');
}}
>
<span>{t('sidebar.delete')}</span>
</button>
</div>
)}
</header>
)}
<div class="session-body app-sidebar">
<Chat
sessionId={sessionId}
onSessionId={handleSessionAssigned}
cwd={cwd}
onPermission={setPending}
pendingPermission={pending}
onPermissionResolved={(callId) =>
setPending((cur: any) =>
callId === null ? null : resolvePendingAfterDecision(cur, callId),
)}
activeSession={activeSession}
restoring={restoring}
onLiveTurnDone={() => setSessionListVersion((v) => v + 1)}
onOptimisticSession={handleOptimisticSession}
onOpenCwd={() => setShowCwd(true)}
onCwdChanged={handleCwdChanged}
onLanding={setIsLanding}
skillInsert={skillInsert}
onSessionRenamed={(name) => setActiveSession((prev) => prev ? { ...prev, name } : prev)}
/>
</div>
</div>
{/* ===== Modals ===== */}
{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)} />
)}
{settingsSection === 'notifications' && (
<NotificationsDialog onClose={() => setSettingsSection(null)} />
)}
{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>
);
}