Files
tkmind_go/ui/goose2/src/app/AppShell.tsx
T
2026-04-22 00:54:48 +00:00

717 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Sidebar } from "@/features/sidebar/ui/Sidebar";
import { StatusBar } from "@/features/status/ui/StatusBar";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { archiveProject } from "@/features/projects/api/projects";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
import type { SectionId } from "@/features/settings/ui/SettingsModal";
import { TopBar } from "./ui/TopBar";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
type ChatSession,
useChatSessionStore,
} from "@/features/chat/stores/chatSessionStore";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { findExistingDraft } from "@/features/chat/lib/newChat";
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
import { useAppStartup } from "./hooks/useAppStartup";
import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync";
import { loadStoredHomeSessionId } from "./lib/homeSessionStorage";
import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference";
import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation";
import { AppShellContent } from "./ui/AppShellContent";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { perfLog } from "@/shared/lib/perfLog";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
export type AppView =
| "home"
| "chat"
| "skills"
| "agents"
| "projects"
| "session-history";
const SIDEBAR_DEFAULT_WIDTH = 240;
const SIDEBAR_MIN_WIDTH = 180;
const SIDEBAR_MAX_WIDTH = 380;
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
const SIDEBAR_COLLAPSED_WIDTH = 48;
export function AppShell({ children }: { children?: React.ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
const [isResizing, setIsResizing] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SectionId>("appearance");
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] =
useState<string | null>(null);
const [editingProject, setEditingProject] = useState<ProjectInfo | null>(
null,
);
const [activeView, setActiveView] = useState<AppView>("home");
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
loadStoredHomeSessionId(),
);
const chatStore = useChatStore();
const sessionStore = useChatSessionStore();
const agentStore = useAgentStore();
const projectStore = useProjectStore();
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
null,
);
const homeSessionRequestRef = useRef<Promise<ChatSession | null> | null>(
null,
);
const loadSessionMessages = useCallback(async (sessionId: string) => {
const sid = sessionId.slice(0, 8);
const existingMsgs = useChatStore.getState().messagesBySession[sessionId];
if ((existingMsgs?.length ?? 0) > 0) {
perfLog(`[perf:load] ${sid} skip — has messages`);
return;
}
const t0 = performance.now();
perfLog(`[perf:load] ${sid} start`);
useChatStore.getState().setSessionLoading(sessionId, true);
try {
const [{ acpLoadSession }, { getReplayPerf, clearReplayPerf }] =
await Promise.all([
import("@/shared/api/acp"),
import("@/shared/api/acpNotificationHandler"),
]);
const t1 = performance.now();
perfLog(`[perf:load] ${sid} import in ${(t1 - t0).toFixed(1)}ms`);
const session = useChatSessionStore.getState().getSession(sessionId);
const gooseSessionId = session?.acpSessionId ?? sessionId;
const project = session?.projectId
? (useProjectStore
.getState()
.projects.find((p) => p.id === session.projectId) ?? null)
: null;
const workingDir = await resolveSessionCwd(project);
await acpLoadSession(sessionId, gooseSessionId, workingDir);
const tFlush = performance.now();
useChatStore.getState().setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
const replayStats = getReplayPerf(sessionId);
clearReplayPerf(sessionId);
if (buffer && buffer.length > 0) {
useChatStore.getState().setMessages(sessionId, buffer);
}
const t2 = performance.now();
perfLog(
`[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`,
);
} catch (err) {
console.error("Failed to load session messages:", err);
clearReplayBuffer(sessionId);
useChatStore.getState().setSessionLoading(sessionId, false);
}
}, []);
useAppStartup();
useEffect(() => {
projectStore.fetchProjects();
}, [projectStore.fetchProjects]);
const { activeSessionId } = sessionStore;
useEffect(() => {
if (activeView === "chat" && activeSessionId) {
useChatStore.getState().markSessionRead(activeSessionId);
}
}, [activeSessionId, activeView]);
const isHome = activeView === "home";
const activeSession = activeSessionId
? sessionStore.getSession(activeSessionId)
: undefined;
const modelName =
activeView === "chat" ? activeSession?.modelName : undefined;
const tokenCount =
activeView === "chat" && activeSessionId
? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens
: 0;
const homeSession = homeSessionId
? sessionStore.getSession(homeSessionId)
: undefined;
useHomeSessionStateSync({
homeSessionId,
homeSession,
messagesBySession: chatStore.messagesBySession,
hasHydratedSessions: sessionStore.hasHydratedSessions,
isLoading: sessionStore.isLoading,
setHomeSessionId,
});
const ensureHomeSession = useCallback(async () => {
if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) {
return null;
}
if (homeSessionRequestRef.current) {
return homeSessionRequestRef.current;
}
const request = (async () => {
if (
homeSession &&
!homeSession.archivedAt &&
homeSession.messageCount === 0
) {
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
agentStore.selectedProvider ?? "goose",
providerInventoryEntries,
);
const project = homeSession.projectId
? (projectStore.projects.find(
(candidate) => candidate.id === homeSession.projectId,
) ?? null)
: null;
const workingDir = await resolveSessionCwd(project);
await acpPrepareSession(
homeSession.id,
sessionModelPreference.providerId,
workingDir,
{
personaId: homeSession.personaId,
},
);
const shouldClearHomeModel =
sessionModelPreference.providerId !== homeSession.providerId ||
!sessionModelPreference.modelId;
sessionStore.updateSession(homeSession.id, {
providerId: sessionModelPreference.providerId,
modelId: shouldClearHomeModel ? undefined : homeSession.modelId,
modelName: shouldClearHomeModel ? undefined : homeSession.modelName,
});
if (sessionModelPreference.modelId) {
await acpSetModel(homeSession.id, sessionModelPreference.modelId);
sessionStore.updateSession(homeSession.id, {
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
}
return homeSession;
}
const workingDir = await resolveSessionCwd(null);
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
agentStore.selectedProvider ?? "goose",
providerInventoryEntries,
);
const session = await sessionStore.createSession({
title: DEFAULT_CHAT_TITLE,
providerId: sessionModelPreference.providerId,
workingDir,
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
setHomeSessionId(session.id);
return session;
})();
homeSessionRequestRef.current = request;
try {
return await request;
} finally {
if (homeSessionRequestRef.current === request) {
homeSessionRequestRef.current = null;
}
}
}, [
agentStore.selectedProvider,
homeSession,
providerInventoryEntries,
projectStore.projects,
sessionStore.hasHydratedSessions,
sessionStore,
sessionStore.isLoading,
]);
useEffect(() => {
if (activeView !== "home") {
return;
}
void ensureHomeSession().catch((error) => {
console.error("Failed to ensure Home session:", error);
});
}, [activeView, ensureHomeSession]);
const createNewTab = useCallback(
async (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => {
const tStart = performance.now();
perfLog(
`[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`,
);
const agentId = agentStore.activeAgentId ?? undefined;
const providerId =
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
providerId,
providerInventoryEntries,
project?.preferredModel ?? undefined,
);
const sessionState = useChatSessionStore.getState();
const chatState = useChatStore.getState();
const existingDraft = findExistingDraft({
sessions: sessionState.sessions,
activeSessionId: sessionState.activeSessionId,
draftsBySession: chatState.draftsBySession,
messagesBySession: chatState.messagesBySession,
request: {
title,
projectId: project?.id,
},
});
if (existingDraft) {
sessionStore.setActiveSession(existingDraft.id);
setActiveView("chat");
chatStore.setActiveSession(existingDraft.id);
perfLog(
`[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`,
);
return existingDraft;
}
const workingDir = await resolveSessionCwd(project);
const session = await sessionStore.createSession({
title,
projectId: project?.id,
agentId,
providerId: sessionModelPreference.providerId,
workingDir,
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
sessionStore.setActiveSession(session.id);
setActiveView("chat");
chatStore.setActiveSession(session.id);
perfLog(
`[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`,
);
return session;
},
[
agentStore.activeAgentId,
agentStore.selectedProvider,
chatStore,
providerInventoryEntries,
sessionStore,
],
);
const handleStartChatFromProject = useCallback(
(project: ProjectInfo) => {
void createNewTab(DEFAULT_CHAT_TITLE, project);
},
[createNewTab],
);
const handleNewChatInProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
if (project) {
void createNewTab(DEFAULT_CHAT_TITLE, project);
}
},
[createNewTab, projectStore.projects],
);
const handleArchiveProject = useCallback(
async (projectId: string) => {
try {
await archiveProject(projectId);
projectStore.fetchProjects();
} catch {
// best-effort
}
},
[projectStore.fetchProjects],
);
const clearActiveSession = useCallback(
(sessionId: string) => {
chatStore.cleanupSession(sessionId);
sessionStore.setActiveSession(null);
setActiveView("home");
},
[chatStore, sessionStore],
);
const openSettings = useCallback((section: SectionId = "appearance") => {
setSettingsInitialSection(section);
setSettingsOpen(true);
}, []);
const handleArchiveChat = useCallback(
async (sessionId: string) => {
const { activeSessionId: currentActiveSessionId } =
useChatSessionStore.getState();
const wasActiveSession = currentActiveSessionId === sessionId;
try {
await sessionStore.archiveSession(sessionId);
chatStore.cleanupSession(sessionId);
if (!wasActiveSession) {
return;
}
sessionStore.setActiveSession(null);
setActiveView("home");
} catch {
// best-effort
}
},
[chatStore, sessionStore],
);
const handleEditProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
if (project) {
setEditingProject(project);
setCreateProjectOpen(true);
}
},
[projectStore.projects],
);
const handleMoveToProject = useCallback(
(sessionId: string, projectId: string | null) => {
sessionStore.updateSession(sessionId, { projectId });
const session = useChatSessionStore.getState().getSession(sessionId);
if (!session) {
return;
}
void (async () => {
const nextProject =
projectId == null
? null
: (useProjectStore
.getState()
.projects.find((project) => project.id === projectId) ?? null);
const workingDir = await resolveSessionCwd(nextProject);
if (!workingDir) {
return;
}
await acpPrepareSession(
sessionId,
session.providerId ?? agentStore.selectedProvider ?? "goose",
workingDir,
{
personaId: session.personaId,
},
);
})().catch((error) => {
console.error(
"Failed to update ACP session project working directory:",
error,
);
});
},
[agentStore.selectedProvider, sessionStore],
);
const handleRenameChat = useCallback(
(sessionId: string, nextTitle: string) => {
sessionStore.updateSession(sessionId, {
title: nextTitle,
userSetName: true,
});
},
[sessionStore],
);
const openCreateProjectDialog = useCallback(
(options?: {
initialWorkingDir?: string | null;
onCreated?: (projectId: string) => void;
}) => {
setEditingProject(null);
setCreateProjectInitialWorkingDir(options?.initialWorkingDir ?? null);
pendingProjectCreatedRef.current = options?.onCreated ?? null;
setCreateProjectOpen(true);
},
[],
);
const activateHomeSession = useCallback(
(sessionId: string) => {
if (homeSessionId === sessionId) {
setHomeSessionId(null);
}
sessionStore.setActiveSession(sessionId);
setActiveView("chat");
chatStore.setActiveSession(sessionId);
useChatStore.getState().markSessionRead(sessionId);
},
[chatStore, homeSessionId, sessionStore],
);
const handleSelectSession = useCallback(
(id: string) => {
sessionStore.setActiveSession(id);
setActiveView("chat");
chatStore.setActiveSession(id);
useChatStore.getState().markSessionRead(id);
loadSessionMessages(id);
},
[sessionStore, chatStore, loadSessionMessages],
);
const handleSelectSearchResult = useCallback(
(sessionId: string, messageId?: string, query?: string) => {
if (messageId) {
useChatStore
.getState()
.setScrollTargetMessage(sessionId, messageId, query);
}
handleSelectSession(sessionId);
},
[handleSelectSession],
);
const handleNavigate = useCallback(
(view: AppView) => {
if (view !== "chat") {
sessionStore.setActiveSession(null);
}
setActiveView(view);
},
[sessionStore],
);
const handleCreatePersona = useCreatePersonaNavigation(() =>
handleNavigate("agents"),
);
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev);
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = sidebarCollapsed
? SIDEBAR_COLLAPSED_WIDTH
: sidebarWidth;
let shouldCollapse = false;
const onMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const newWidth = startWidth + delta;
if (newWidth < SIDEBAR_SNAP_COLLAPSE_THRESHOLD) {
shouldCollapse = true;
setSidebarWidth(SIDEBAR_MIN_WIDTH);
} else {
shouldCollapse = false;
setSidebarCollapsed(false);
setSidebarWidth(
Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, newWidth)),
);
}
};
const cleanup = () => {
setIsResizing(false);
if (shouldCollapse) setSidebarCollapsed(true);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", cleanup);
window.removeEventListener("blur", cleanup);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", cleanup);
window.addEventListener("blur", cleanup);
},
[sidebarCollapsed, sidebarWidth],
);
const handleResizeDoubleClick = useCallback(() => {
setSidebarCollapsed(false);
setSidebarWidth(SIDEBAR_DEFAULT_WIDTH);
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd+, for settings
if (e.key === "," && e.metaKey) {
e.preventDefault();
setSettingsOpen((prev) => !prev);
}
// Cmd+B for sidebar toggle
if (e.key === "b" && e.metaKey) {
e.preventDefault();
setSidebarCollapsed((prev) => !prev);
}
// Cmd+W returns to home instead of closing the window
if (e.key === "w" && e.metaKey) {
e.preventDefault();
const { activeSessionId } = useChatSessionStore.getState();
if (activeSessionId) {
clearActiveSession(activeSessionId);
}
}
// Cmd+N opens new conversation screen
if (e.key === "n" && e.metaKey) {
e.preventDefault();
sessionStore.setActiveSession(null);
setActiveView("home");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [clearActiveSession, sessionStore]);
const editingProjectProp = useMemo(
() =>
editingProject
? {
id: editingProject.id,
name: editingProject.name,
description: editingProject.description,
prompt: editingProject.prompt,
icon: editingProject.icon,
color: editingProject.color,
preferredProvider: editingProject.preferredProvider,
preferredModel: editingProject.preferredModel,
workingDirs: editingProject.workingDirs,
useWorktrees: editingProject.useWorktrees,
}
: undefined,
[editingProject],
);
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<TopBar onSettingsClick={() => openSettings()} />
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="flex-shrink-0 h-full py-3 pl-3"
style={{
width: sidebarCollapsed
? SIDEBAR_COLLAPSED_WIDTH + 12
: sidebarWidth + 12,
transition: isResizing ? "none" : "width 200ms ease-out",
}}
>
<Sidebar
collapsed={sidebarCollapsed}
width={sidebarWidth}
isResizing={isResizing}
onCollapse={toggleSidebar}
onNavigate={handleNavigate}
onNewChatInProject={handleNewChatInProject}
onNewChat={() => {
sessionStore.setActiveSession(null);
setActiveView("home");
}}
onCreateProject={() => openCreateProjectDialog()}
onEditProject={handleEditProject}
onArchiveProject={handleArchiveProject}
onArchiveChat={handleArchiveChat}
onRenameChat={handleRenameChat}
onMoveToProject={handleMoveToProject}
onReorderProject={projectStore.reorderProjects}
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}
activeView={activeView}
activeSessionId={activeSessionId}
projects={projectStore.projects}
className="h-full rounded-xl"
/>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle for sidebar resize */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
className="flex-shrink-0 w-2 h-full cursor-col-resize group flex items-center justify-center"
>
<div className="w-px h-8 rounded-full bg-transparent group-hover:bg-border transition-colors" />
</div>
<main className="min-h-0 min-w-0 flex-1">
{children ?? (
<AppShellContent
activeView={activeView}
activeSession={activeSession}
homeSessionId={homeSessionId}
onCreatePersona={handleCreatePersona}
onArchiveChat={handleArchiveChat}
onCreateProject={openCreateProjectDialog}
onActivateHomeSession={activateHomeSession}
onRenameChat={handleRenameChat}
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}
onStartChatFromProject={handleStartChatFromProject}
/>
)}
</main>
</div>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isHome ? "max-h-0 opacity-0" : "max-h-8 opacity-100"
}`}
>
<StatusBar
modelName={modelName}
sessionId={activeSessionId ?? undefined}
tokenCount={tokenCount}
/>
</div>
{settingsOpen && (
<SettingsModal
initialSection={settingsInitialSection}
onClose={() => setSettingsOpen(false)}
/>
)}
<CreateProjectDialog
isOpen={createProjectOpen}
onClose={() => {
setCreateProjectOpen(false);
setEditingProject(null);
setCreateProjectInitialWorkingDir(null);
pendingProjectCreatedRef.current = null;
}}
onCreated={(project) => {
projectStore.fetchProjects();
pendingProjectCreatedRef.current?.(project.id);
pendingProjectCreatedRef.current = null;
setCreateProjectInitialWorkingDir(null);
}}
initialWorkingDir={createProjectInitialWorkingDir}
editingProject={editingProjectProp}
/>
</div>
);
}