overhaul provider inventory and agent/model selection (#8652)

Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-04-20 15:00:17 -07:00
committed by GitHub
parent 3d582943fd
commit 8eda6fdabc
70 changed files with 5321 additions and 2123 deletions
+188 -104
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Sidebar } from "@/features/sidebar/ui/Sidebar";
import { StatusBar } from "@/features/status/ui/StatusBar";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { archiveProject } from "@/features/projects/api/projects";
import type { ProjectInfo } from "@/features/projects/api/projects";
@@ -9,7 +8,11 @@ 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 { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import {
type ChatSession,
hasSessionStarted,
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";
@@ -37,6 +40,33 @@ const SIDEBAR_MIN_WIDTH = 180;
const SIDEBAR_MAX_WIDTH = 380;
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
const SIDEBAR_COLLAPSED_WIDTH = 48;
const HOME_SESSION_STORAGE_KEY = "goose:home-session-id";
function loadStoredHomeSessionId(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY);
} catch {
return null;
}
}
function persistHomeSessionId(sessionId: string | null): void {
if (typeof window === "undefined") {
return;
}
try {
if (sessionId) {
window.localStorage.setItem(HOME_SESSION_STORAGE_KEY, sessionId);
return;
}
window.localStorage.removeItem(HOME_SESSION_STORAGE_KEY);
} catch {
// localStorage may be unavailable
}
}
export function AppShell({ children }: { children?: React.ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -52,9 +82,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
null,
);
const [activeView, setActiveView] = useState<AppView>("home");
const [homeSelectedProvider, setHomeSelectedProvider] = useState<
string | undefined
>();
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
loadStoredHomeSessionId(),
);
const chatStore = useChatStore();
const sessionStore = useChatSessionStore();
@@ -64,6 +94,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
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);
@@ -125,70 +158,146 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}
}, [activeSessionId, activeView]);
const isHome = activeSessionId === null && activeView === "home";
const isHome = activeView === "home";
const activeSession = activeSessionId
? sessionStore.getSession(activeSessionId)
: undefined;
const modelName = activeSession?.modelName;
const tokenCount = activeSessionId
? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens
: 0;
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;
const [pendingInitialMessage, setPendingInitialMessage] = useState<
string | undefined
>();
const [pendingInitialAttachments, setPendingInitialAttachments] = useState<
ChatAttachmentDraft[] | undefined
>();
const [homeSelectedPersonaId, setHomeSelectedPersonaId] = useState<
string | undefined
>();
useEffect(() => {
if (
!homeSessionId ||
!sessionStore.hasHydratedSessions ||
sessionStore.isLoading
) {
return;
}
if (
!homeSession ||
homeSession.archivedAt ||
hasSessionStarted(
homeSession,
chatStore.messagesBySession[homeSession.id],
)
) {
setHomeSessionId(null);
}
}, [
chatStore.messagesBySession,
homeSession,
homeSession?.archivedAt,
homeSession?.messageCount,
homeSessionId,
sessionStore.hasHydratedSessions,
sessionStore.isLoading,
]);
const cleanupEmptyDraft = useCallback(
(sessionId: string | null) => {
if (!sessionId) return;
const state = useChatSessionStore.getState();
const session = state.sessions.find((s) => s.id === sessionId);
if (!session?.draft) return;
const draft = useChatStore.getState().draftsBySession[sessionId] ?? "";
if (draft.length > 0) return; // has typed text — keep it
chatStore.cleanupSession(sessionId);
state.removeDraft(sessionId);
},
[chatStore],
);
useEffect(() => {
persistHomeSessionId(homeSessionId);
}, [homeSessionId]);
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 project = homeSession.projectId
? (projectStore.projects.find(
(candidate) => candidate.id === homeSession.projectId,
) ?? null)
: null;
const workingDir = await resolveSessionCwd(project);
await acpPrepareSession(
homeSession.id,
homeSession.providerId ?? agentStore.selectedProvider ?? "goose",
workingDir,
{
personaId: homeSession.personaId,
},
);
return homeSession;
}
const workingDir = await resolveSessionCwd(null);
const session = await sessionStore.createSession({
title: DEFAULT_CHAT_TITLE,
providerId: agentStore.selectedProvider ?? "goose",
workingDir,
});
setHomeSessionId(session.id);
return session;
})();
homeSessionRequestRef.current = request;
try {
return await request;
} finally {
if (homeSessionRequestRef.current === request) {
homeSessionRequestRef.current = null;
}
}
}, [
agentStore.selectedProvider,
homeSession,
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(
(title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => {
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 ?? homeSelectedProvider;
const personaId = homeSelectedPersonaId;
const providerId =
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
const modelId = project?.preferredModel ?? undefined;
const sessionState = useChatSessionStore.getState();
const chatStoreState = useChatStore.getState();
const chatState = useChatStore.getState();
const existingDraft = findExistingDraft({
sessions: sessionState.sessions,
activeSessionId: sessionState.activeSessionId,
draftsBySession: chatStoreState.draftsBySession,
messagesBySession: chatStoreState.messagesBySession,
draftsBySession: chatState.draftsBySession,
messagesBySession: chatState.messagesBySession,
request: {
title,
projectId: project?.id,
agentId,
providerId,
personaId,
},
});
if (existingDraft) {
if (sessionState.activeSessionId !== existingDraft.id) {
cleanupEmptyDraft(sessionState.activeSessionId);
}
sessionState.setActiveSession(existingDraft.id);
sessionStore.setActiveSession(existingDraft.id);
setActiveView("chat");
chatStore.setActiveSession(existingDraft.id);
perfLog(
@@ -196,46 +305,45 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
);
return existingDraft;
}
cleanupEmptyDraft(sessionState.activeSessionId);
const session = sessionStore.createDraftSession({
const workingDir = await resolveSessionCwd(project);
const session = await sessionStore.createSession({
title,
projectId: project?.id,
agentId,
providerId,
personaId,
workingDir,
modelId,
modelName: modelId,
});
sessionStore.setActiveSession(session.id);
setActiveView("chat");
chatStore.setActiveSession(session.id);
perfLog(
`[perf:newtab] ${session.id.slice(0, 8)} created draft in ${(performance.now() - tStart).toFixed(1)}ms`,
`[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`,
);
return session;
},
[
agentStore.activeAgentId,
agentStore.selectedProvider,
chatStore,
sessionStore,
agentStore.activeAgentId,
homeSelectedPersonaId,
homeSelectedProvider,
cleanupEmptyDraft,
],
);
const handleStartChatFromProject = useCallback(
(project: ProjectInfo) => {
setHomeSelectedProvider(undefined);
createNewTab(DEFAULT_CHAT_TITLE, project);
void createNewTab(DEFAULT_CHAT_TITLE, project);
},
[createNewTab],
);
const handleNewChatInProject = useCallback(
(projectId: string) => {
setHomeSelectedProvider(undefined);
const project = projectStore.projects.find((p) => p.id === projectId);
if (project) {
createNewTab(DEFAULT_CHAT_TITLE, project);
void createNewTab(DEFAULT_CHAT_TITLE, project);
}
},
[createNewTab, projectStore.projects],
@@ -255,12 +363,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const clearActiveSession = useCallback(
(sessionId: string) => {
cleanupEmptyDraft(sessionId);
chatStore.cleanupSession(sessionId);
sessionStore.setActiveSession(null);
setActiveView("home");
},
[chatStore, sessionStore, cleanupEmptyDraft],
[chatStore, sessionStore],
);
const openSettings = useCallback((section: SectionId = "appearance") => {
setSettingsInitialSection(section);
@@ -306,7 +413,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
sessionStore.updateSession(sessionId, { projectId });
const session = useChatSessionStore.getState().getSession(sessionId);
if (!session || session.draft) {
if (!session) {
return;
}
@@ -362,41 +469,28 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
[],
);
const handleHomeStartChat = useCallback(
(
initialMessage?: string,
providerId?: string,
personaId?: string,
projectId?: string | null,
attachments?: ChatAttachmentDraft[],
) => {
setHomeSelectedProvider(providerId);
setHomeSelectedPersonaId(personaId);
setPendingInitialMessage(initialMessage);
setPendingInitialAttachments(attachments);
const selectedProject =
projectId != null
? projectStore.projects.find((project) => project.id === projectId)
: undefined;
createNewTab(
initialMessage?.slice(0, 40) || DEFAULT_CHAT_TITLE,
selectedProject,
);
const activateHomeSession = useCallback(
(sessionId: string) => {
if (homeSessionId === sessionId) {
setHomeSessionId(null);
}
sessionStore.setActiveSession(sessionId);
setActiveView("chat");
chatStore.setActiveSession(sessionId);
useChatStore.getState().markSessionRead(sessionId);
},
[createNewTab, projectStore.projects],
[chatStore, homeSessionId, sessionStore],
);
const handleSelectSession = useCallback(
(id: string) => {
cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId);
sessionStore.setActiveSession(id);
setActiveView("chat");
chatStore.setActiveSession(id);
useChatStore.getState().markSessionRead(id);
loadSessionMessages(id);
},
[sessionStore, chatStore, loadSessionMessages, cleanupEmptyDraft],
[sessionStore, chatStore, loadSessionMessages],
);
const handleSelectSearchResult = useCallback(
@@ -414,12 +508,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const handleNavigate = useCallback(
(view: AppView) => {
if (view !== "chat") {
cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId);
sessionStore.setActiveSession(null);
}
setActiveView(view);
},
[sessionStore, cleanupEmptyDraft],
[sessionStore],
);
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev);
@@ -496,20 +589,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
// Cmd+N opens new conversation screen
if (e.key === "n" && e.metaKey) {
e.preventDefault();
createNewTab();
sessionStore.setActiveSession(null);
setActiveView("home");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [clearActiveSession, createNewTab]);
const activeSessionPersonaId = activeSession?.personaId;
const handleInitialMessageConsumed = useCallback(() => {
setPendingInitialMessage(undefined);
setPendingInitialAttachments(undefined);
setHomeSelectedProvider(undefined);
setHomeSelectedPersonaId(undefined);
}, []);
}, [clearActiveSession, sessionStore]);
const editingProjectProp = useMemo(
() =>
@@ -551,7 +637,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onCollapse={toggleSidebar}
onNavigate={handleNavigate}
onNewChatInProject={handleNewChatInProject}
onNewChat={() => createNewTab()}
onNewChat={() => {
sessionStore.setActiveSession(null);
setActiveView("home");
}}
onCreateProject={() => openCreateProjectDialog()}
onEditProject={handleEditProject}
onArchiveProject={handleArchiveProject}
@@ -582,15 +671,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
<AppShellContent
activeView={activeView}
activeSession={activeSession}
activeSessionPersonaId={activeSessionPersonaId}
homeSelectedProvider={homeSelectedProvider}
homeSelectedPersonaId={homeSelectedPersonaId}
pendingInitialMessage={pendingInitialMessage}
pendingInitialAttachments={pendingInitialAttachments}
homeSessionId={homeSessionId}
onArchiveChat={handleArchiveChat}
onCreateProject={openCreateProjectDialog}
onHomeStartChat={handleHomeStartChat}
onInitialMessageConsumed={handleInitialMessageConsumed}
onActivateHomeSession={activateHomeSession}
onRenameChat={handleRenameChat}
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}