Skip to content
119 changes: 53 additions & 66 deletions ui/goose2/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,25 @@ import { TopBar } from "./ui/TopBar";
import { useChatStore } from "@/features/chat/stores/chatStore";
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";
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 { AppShellContent } from "./ui/AppShellContent";
import { acpPrepareSession } from "@/shared/api/acp";
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"
Expand All @@ -40,34 +43,6 @@ 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);
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
Expand All @@ -90,6 +65,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const sessionStore = useChatSessionStore();
const agentStore = useAgentStore();
const projectStore = useProjectStore();
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);

const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
null,
Expand Down Expand Up @@ -173,37 +149,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
? sessionStore.getSession(homeSessionId)
: 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,
useHomeSessionStateSync({
homeSessionId,
sessionStore.hasHydratedSessions,
sessionStore.isLoading,
]);

useEffect(() => {
persistHomeSessionId(homeSessionId);
}, [homeSessionId]);
homeSession,
messagesBySession: chatStore.messagesBySession,
hasHydratedSessions: sessionStore.hasHydratedSessions,
isLoading: sessionStore.isLoading,
setHomeSessionId,
});

const ensureHomeSession = useCallback(async () => {
if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) {
Expand All @@ -220,6 +173,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
!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,
Expand All @@ -228,20 +186,42 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const workingDir = await resolveSessionCwd(project);
await acpPrepareSession(
homeSession.id,
homeSession.providerId ?? agentStore.selectedProvider ?? "goose",
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);
Comment thread
morgmart marked this conversation as resolved.
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: agentStore.selectedProvider ?? "goose",
providerId: sessionModelPreference.providerId,
workingDir,
modelId: sessionModelPreference.modelId,
Comment thread
morgmart marked this conversation as resolved.
modelName: sessionModelPreference.modelName,
});
setHomeSessionId(session.id);
return session;
Expand All @@ -258,6 +238,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}, [
agentStore.selectedProvider,
homeSession,
providerInventoryEntries,
projectStore.projects,
sessionStore.hasHydratedSessions,
sessionStore,
Expand All @@ -282,7 +263,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const agentId = agentStore.activeAgentId ?? undefined;
const providerId =
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
const modelId = project?.preferredModel ?? undefined;
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
providerId,
providerInventoryEntries,
project?.preferredModel ?? undefined,
);
const sessionState = useChatSessionStore.getState();
const chatState = useChatStore.getState();
const existingDraft = findExistingDraft({
Expand Down Expand Up @@ -311,10 +297,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
title,
projectId: project?.id,
agentId,
providerId,
providerId: sessionModelPreference.providerId,
workingDir,
modelId,
modelName: modelId,
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
sessionStore.setActiveSession(session.id);
setActiveView("chat");
Expand All @@ -328,6 +314,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
agentStore.activeAgentId,
agentStore.selectedProvider,
chatStore,
providerInventoryEntries,
sessionStore,
],
);
Expand Down
51 changes: 51 additions & 0 deletions ui/goose2/src/app/hooks/useHomeSessionStateSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import {
hasSessionStarted,
type ChatSession,
} from "@/features/chat/stores/chatSessionStore";
import { persistHomeSessionId } from "../lib/homeSessionStorage";

interface UseHomeSessionStateSyncOptions {
homeSessionId: string | null;
homeSession?: ChatSession;
messagesBySession: Record<string, ArrayLike<unknown> | undefined>;
hasHydratedSessions: boolean;
isLoading: boolean;
setHomeSessionId: (sessionId: string | null) => void;
}

export function useHomeSessionStateSync({
homeSessionId,
homeSession,
messagesBySession,
hasHydratedSessions,
isLoading,
setHomeSessionId,
}: UseHomeSessionStateSyncOptions): void {
useEffect(() => {
if (!homeSessionId || !hasHydratedSessions || isLoading) {
return;
}

if (
!homeSession ||
homeSession.archivedAt ||
hasSessionStarted(homeSession, messagesBySession[homeSession.id])
) {
setHomeSessionId(null);
}
}, [
hasHydratedSessions,
homeSession,
homeSession?.archivedAt,
homeSession?.messageCount,
homeSessionId,
isLoading,
messagesBySession,
setHomeSessionId,
]);

useEffect(() => {
persistHomeSessionId(homeSessionId);
}, [homeSessionId]);
}
27 changes: 27 additions & 0 deletions ui/goose2/src/app/lib/homeSessionStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const HOME_SESSION_STORAGE_KEY = "goose:home-session-id";

export function loadStoredHomeSessionId(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY);
} catch {
return null;
}
}

export 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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSupportedSessionModelPreference } from "./resolveSupportedSessionModelPreference";

const mockGetProviderInventory = vi.fn();

vi.mock("@/features/providers/api/inventory", () => ({
getProviderInventory: (...args: unknown[]) =>
mockGetProviderInventory(...args),
}));

describe("resolveSupportedSessionModelPreference", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
});

it("drops the model when provider inventory lookup fails", async () => {
window.localStorage.setItem(
"goose:preferredModelsByAgent",
JSON.stringify({
goose: {
modelId: "gpt-5.4",
modelName: "GPT-5.4",
providerId: "openai",
},
}),
);
mockGetProviderInventory.mockRejectedValue(
new Error("inventory unavailable"),
);

await expect(
resolveSupportedSessionModelPreference("goose", new Map()),
).resolves.toEqual({
providerId: "openai",
});
});

it("drops the model when provider inventory has no matching entry", async () => {
window.localStorage.setItem(
"goose:preferredModelsByAgent",
JSON.stringify({
goose: {
modelId: "gpt-5.4",
modelName: "GPT-5.4",
providerId: "openai",
},
}),
);
mockGetProviderInventory.mockResolvedValue([]);

await expect(
resolveSupportedSessionModelPreference("goose", new Map()),
).resolves.toEqual({
providerId: "openai",
});
});
});
36 changes: 36 additions & 0 deletions ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
import { getProviderInventory } from "@/features/providers/api/inventory";
import {
resolveSessionModelPreference,
sanitizeSessionModelPreference,
type SessionModelPreference,
} from "@/features/chat/lib/sessionModelPreference";

export async function resolveSupportedSessionModelPreference(
providerId: string,
inventoryEntries: Map<string, ProviderInventoryEntryDto>,
preferredModel?: string,
): Promise<SessionModelPreference> {
const sessionModelPreference = resolveSessionModelPreference({
providerId,
preferredModel,
});

if (!sessionModelPreference.modelId) {
return sessionModelPreference;
}

const inventoryEntry =
inventoryEntries.get(sessionModelPreference.providerId) ??
(await getProviderInventory([sessionModelPreference.providerId])
.then(([entry]) => entry)
.catch(() => undefined));

if (!inventoryEntry) {
return {
providerId: sessionModelPreference.providerId,
};
}

return sanitizeSessionModelPreference(sessionModelPreference, inventoryEntry);
}
Loading
Loading