fix(goose2): keep model picker and session config in sync (#9048)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-05-07 17:30:40 +10:00
committed by GitHub
parent f325a9d8e3
commit f4fe8d5d7f
36 changed files with 1893 additions and 389 deletions
+80 -37
View File
@@ -33,11 +33,8 @@ import { loadStoredHomeSessionId } from "./lib/homeSessionStorage";
import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference"; import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference";
import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation"; import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation";
import { AppShellContent } from "./ui/AppShellContent"; import { AppShellContent } from "./ui/AppShellContent";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; import { applyLatestSessionConfig } from "@/features/chat/lib/sessionConfigRequests";
import { import { updateSessionTitle } from "@/features/chat/stores/chatSessionOperations";
updateSessionProject,
updateSessionTitle,
} from "@/features/chat/stores/chatSessionOperations";
import { import {
clearReplayBuffer, clearReplayBuffer,
getAndDeleteReplayBuffer, getAndDeleteReplayBuffer,
@@ -244,14 +241,32 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
} }
const request = (async () => { const request = (async () => {
const currentProvider = () =>
useAgentStore.getState().selectedProvider ?? "goose";
// Resolve the provider to use after an async gap. If the user changed
// their selection while we were awaiting (liveProvider differs from what
// it was before the await), prefer the live value; otherwise use the
// model-preference resolution result.
const resolveProviderAfterAwait = (
providerAtStart: string,
sessionModelPreference: { providerId: string },
): string => {
const liveProvider = currentProvider();
return liveProvider !== providerAtStart
? liveProvider
: sessionModelPreference.providerId;
};
if ( if (
homeSession && homeSession &&
!homeSession.archivedAt && !homeSession.archivedAt &&
homeSession.messageCount === 0 homeSession.messageCount === 0
) { ) {
const providerAtStart = currentProvider();
const sessionModelPreference = const sessionModelPreference =
await resolveSupportedSessionModelPreference( await resolveSupportedSessionModelPreference(
selectedProvider ?? "goose", providerAtStart,
providerInventoryEntries, providerInventoryEntries,
); );
const project = homeSession.projectId const project = homeSession.projectId
@@ -260,41 +275,67 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
) ?? null) ) ?? null)
: null; : null;
const workingDir = await resolveSessionCwd(project); const workingDir = await resolveSessionCwd(project);
await acpPrepareSession( const resolvedProviderId = resolveProviderAfterAwait(
homeSession.id, providerAtStart,
sessionModelPreference.providerId, sessionModelPreference,
workingDir,
); );
const shouldClearHomeModel = const modelIdToApply =
sessionModelPreference.providerId !== homeSession.providerId || resolvedProviderId === sessionModelPreference.providerId
!sessionModelPreference.modelId; ? sessionModelPreference.modelId
patchSession(homeSession.id, { : undefined;
providerId: sessionModelPreference.providerId, const result = await applyLatestSessionConfig({
modelId: shouldClearHomeModel ? undefined : homeSession.modelId, sessionId: homeSession.id,
modelName: shouldClearHomeModel ? undefined : homeSession.modelName, providerId: resolvedProviderId,
workingDir,
modelId: modelIdToApply,
}); });
if (sessionModelPreference.modelId) { if (!result.applied) {
await acpSetModel(homeSession.id, sessionModelPreference.modelId); return homeSession;
patchSession(homeSession.id, {
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
} }
return homeSession;
const shouldClearHomeModel =
resolvedProviderId !== homeSession.providerId || !modelIdToApply;
patchSession(homeSession.id, {
providerId: resolvedProviderId,
modelId:
modelIdToApply ??
(shouldClearHomeModel ? undefined : homeSession.modelId),
modelName:
modelIdToApply != null
? sessionModelPreference.modelName
: shouldClearHomeModel
? undefined
: homeSession.modelName,
});
return (
useChatSessionStore.getState().getSession(homeSession.id) ??
homeSession
);
} }
const providerAtStart = currentProvider();
const workingDir = await resolveSessionCwd(null); const workingDir = await resolveSessionCwd(null);
const sessionModelPreference = const sessionModelPreference =
await resolveSupportedSessionModelPreference( await resolveSupportedSessionModelPreference(
selectedProvider ?? "goose", providerAtStart,
providerInventoryEntries, providerInventoryEntries,
); );
const resolvedProviderId = resolveProviderAfterAwait(
providerAtStart,
sessionModelPreference,
);
const session = await createSession({ const session = await createSession({
title: DEFAULT_CHAT_TITLE, title: DEFAULT_CHAT_TITLE,
providerId: sessionModelPreference.providerId, providerId: resolvedProviderId,
workingDir, workingDir,
modelId: sessionModelPreference.modelId, modelId:
modelName: sessionModelPreference.modelName, resolvedProviderId === sessionModelPreference.providerId
? sessionModelPreference.modelId
: undefined,
modelName:
resolvedProviderId === sessionModelPreference.providerId
? sessionModelPreference.modelName
: undefined,
}); });
setHomeSessionId(session.id); setHomeSessionId(session.id);
return session; return session;
@@ -309,7 +350,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
} }
} }
}, [ }, [
selectedProvider,
createSession, createSession,
hasHydratedSessions, hasHydratedSessions,
homeSession, homeSession,
@@ -512,14 +552,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const handleMoveToProject = useCallback( const handleMoveToProject = useCallback(
(sessionId: string, projectId: string | null) => { (sessionId: string, projectId: string | null) => {
useChatSessionStore.getState().patchSession(sessionId, { projectId });
const session = useChatSessionStore.getState().getSession(sessionId); const session = useChatSessionStore.getState().getSession(sessionId);
if (!session) { if (!session) {
return; return;
} }
void (async () => { void (async () => {
await updateSessionProject(sessionId, projectId);
const nextProject = const nextProject =
projectId == null projectId == null
? null ? null
@@ -530,17 +570,20 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
if (!workingDir) { if (!workingDir) {
return; return;
} }
await acpPrepareSession( await applyLatestSessionConfig({
sessionId, sessionId,
session.providerId ?? selectedProvider ?? "goose", providerId: session.providerId ?? selectedProvider ?? "goose",
workingDir, workingDir,
); modelId: session.modelId,
});
})().catch((error) => { })().catch((error) => {
console.error("Failed to move chat to project:", error); console.error(
toast.error(t("notifications.moveError")); "Failed to update ACP session project working directory:",
error,
);
}); });
}, },
[selectedProvider, t], [selectedProvider],
); );
const handleRenameChat = useCallback( const handleRenameChat = useCallback(
+4 -2
View File
@@ -65,6 +65,7 @@ export function useAppStartup() {
const applyProvidersFromInventory = ( const applyProvidersFromInventory = (
entries: Parameters<typeof discoverAcpProvidersFromEntries>[0], entries: Parameters<typeof discoverAcpProvidersFromEntries>[0],
validated = false,
) => { ) => {
const providers = discoverAcpProvidersFromEntries(entries); const providers = discoverAcpProvidersFromEntries(entries);
const providerAllowlist = parseProviderAllowlist( const providerAllowlist = parseProviderAllowlist(
@@ -76,6 +77,7 @@ export function useAppStartup() {
providerAllowlist, providerAllowlist,
getModelProviders(), getModelProviders(),
), ),
validated,
); );
return providers; return providers;
}; };
@@ -116,7 +118,7 @@ export function useAppStartup() {
...useProviderInventoryStore.getState().entries.values(), ...useProviderInventoryStore.getState().entries.values(),
]; ];
if (inventoryEntries.length > 0) { if (inventoryEntries.length > 0) {
applyProvidersFromInventory(inventoryEntries); applyProvidersFromInventory(inventoryEntries, true);
} }
perfLog( perfLog(
`[perf:startup] loadProviderCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`, `[perf:startup] loadProviderCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`,
@@ -140,7 +142,7 @@ export function useAppStartup() {
inventoryStore.setEntries(entries); inventoryStore.setEntries(entries);
// Derive ACP providers from the same response // Derive ACP providers from the same response
const providers = applyProvidersFromInventory(entries); const providers = applyProvidersFromInventory(entries, true);
perfLog( perfLog(
`[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`, `[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`,
@@ -55,4 +55,28 @@ describe("resolveSupportedSessionModelPreference", () => {
providerId: "openai", providerId: "openai",
}); });
}); });
it("preserves an exact stored provider model while inventory is unavailable", async () => {
window.localStorage.setItem(
"goose:preferredModelsByAgent",
JSON.stringify({
"claude-acp": {
modelId: "opus",
modelName: "Claude Opus",
providerId: "claude-acp",
},
}),
);
mockGetProviderInventory.mockRejectedValue(
new Error("inventory unavailable"),
);
await expect(
resolveSupportedSessionModelPreference("claude-acp", new Map()),
).resolves.toEqual({
providerId: "claude-acp",
modelId: "opus",
modelName: "Claude Opus",
});
});
}); });
@@ -5,6 +5,7 @@ import {
sanitizeSessionModelPreference, sanitizeSessionModelPreference,
type SessionModelPreference, type SessionModelPreference,
} from "@/features/chat/lib/sessionModelPreference"; } from "@/features/chat/lib/sessionModelPreference";
import { getStoredModelPreference } from "@/features/chat/lib/modelPreferences";
export async function resolveSupportedSessionModelPreference( export async function resolveSupportedSessionModelPreference(
providerId: string, providerId: string,
@@ -20,6 +21,15 @@ export async function resolveSupportedSessionModelPreference(
return sessionModelPreference; return sessionModelPreference;
} }
const exactStoredPreference = preferredModel
? null
: getStoredModelPreference(providerId);
const shouldPreserveWithoutInventory =
sessionModelPreference.providerId === providerId &&
exactStoredPreference?.modelId === sessionModelPreference.modelId &&
(exactStoredPreference.providerId ?? providerId) ===
sessionModelPreference.providerId;
const inventoryEntry = const inventoryEntry =
inventoryEntries.get(sessionModelPreference.providerId) ?? inventoryEntries.get(sessionModelPreference.providerId) ??
(await getProviderInventory([sessionModelPreference.providerId]) (await getProviderInventory([sessionModelPreference.providerId])
@@ -27,6 +37,10 @@ export async function resolveSupportedSessionModelPreference(
.catch(() => undefined)); .catch(() => undefined));
if (!inventoryEntry) { if (!inventoryEntry) {
if (shouldPreserveWithoutInventory) {
return sessionModelPreference;
}
return { return {
providerId: sessionModelPreference.providerId, providerId: sessionModelPreference.providerId,
}; };
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { afterEach, describe, it, expect, beforeEach } from "vitest";
import { useAgentStore } from "../agentStore"; import { useAgentStore } from "../agentStore";
import type { Persona, Agent } from "@/shared/types/agents"; import type { Persona, Agent } from "@/shared/types/agents";
@@ -202,3 +202,50 @@ describe("agentStore", () => {
expect(custom[0].id).toBe("c"); expect(custom[0].id).toBe("c");
}); });
}); });
describe("agentStore.setProviders", () => {
beforeEach(() => {
localStorage.clear();
useAgentStore.setState({
providers: [],
providersLoading: false,
selectedProvider: "claude-acp",
});
localStorage.setItem("goose:defaultProvider", "claude-acp");
});
afterEach(() => {
localStorage.clear();
});
it("does not overwrite stored provider during unvalidated hydration", () => {
useAgentStore
.getState()
.setProviders([{ id: "goose", label: "Goose" }], false);
expect(useAgentStore.getState().selectedProvider).toBe("claude-acp");
expect(localStorage.getItem("goose:defaultProvider")).toBe("claude-acp");
});
it("falls back and persists when validated and provider is missing", () => {
useAgentStore
.getState()
.setProviders([{ id: "goose", label: "Goose" }], true);
expect(useAgentStore.getState().selectedProvider).toBe("goose");
expect(localStorage.getItem("goose:defaultProvider")).toBe("goose");
});
it("keeps valid provider during validated hydration", () => {
useAgentStore.getState().setProviders(
[
{ id: "goose", label: "Goose" },
{ id: "claude-acp", label: "Claude Code" },
],
true,
);
expect(useAgentStore.getState().selectedProvider).toBe("claude-acp");
expect(localStorage.getItem("goose:defaultProvider")).toBe("claude-acp");
});
});
@@ -75,7 +75,7 @@ interface AgentStoreActions {
setAgentsLoading: (loading: boolean) => void; setAgentsLoading: (loading: boolean) => void;
// Provider management // Provider management
setProviders: (providers: AcpProvider[]) => void; setProviders: (providers: AcpProvider[], validated?: boolean) => void;
setProvidersLoading: (loading: boolean) => void; setProvidersLoading: (loading: boolean) => void;
setSelectedProvider: (providerId: string, persist?: boolean) => void; setSelectedProvider: (providerId: string, persist?: boolean) => void;
@@ -163,10 +163,10 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
setAgentsLoading: (agentsLoading) => set({ agentsLoading }), setAgentsLoading: (agentsLoading) => set({ agentsLoading }),
// Provider management // Provider management
setProviders: (providers) => { setProviders: (providers, validated = true) => {
const { selectedProvider } = get(); const { selectedProvider } = get();
const isValid = providers.some((p) => p.id === selectedProvider); const isValid = providers.some((p) => p.id === selectedProvider);
if (!isValid && providers.length > 0) { if (!isValid && providers.length > 0 && validated) {
const fallback = providers[0].id; const fallback = providers[0].id;
persistProvider(fallback); persistProvider(fallback);
set({ providers, selectedProvider: fallback }); set({ providers, selectedProvider: fallback });
@@ -122,6 +122,7 @@ describe("useChat compaction", () => {
let preparedPersonaId: string | undefined; let preparedPersonaId: string | undefined;
const ensurePrepared = vi.fn(async (personaId?: string) => { const ensurePrepared = vi.fn(async (personaId?: string) => {
preparedPersonaId = personaId; preparedPersonaId = personaId;
return undefined;
}); });
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -264,4 +265,40 @@ describe("useChat compaction", () => {
expect(runtime.error).toBe("prepare failed"); expect(runtime.error).toBe("prepare failed");
expect(runtime.chatState).toBe("idle"); expect(runtime.chatState).toBe("idle");
}); });
it("does not compact when preparation is superseded", async () => {
const ensurePrepared = vi.fn().mockResolvedValue(false);
const { result } = renderHook(() =>
useChat("session-1", undefined, undefined, undefined, {
ensurePrepared,
}),
);
let compactResult: unknown;
await act(async () => {
compactResult = await result.current.compactConversation();
});
expect(compactResult).toBe("failed");
expect(ensurePrepared).toHaveBeenCalledWith(undefined);
expect(mockAcpSendMessage).not.toHaveBeenCalled();
expect(mockAcpLoadSession).not.toHaveBeenCalled();
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(messages).toHaveLength(1);
expect(messages[0].content).toEqual([
{
type: "systemNotification",
notificationType: "error",
text: "Session configuration changed while preparing. Try sending again.",
},
]);
expect(runtime.error).toBe(
"Session configuration changed while preparing. Try sending again.",
);
expect(runtime.chatState).toBe("idle");
});
}); });
@@ -331,6 +331,41 @@ describe("useChat", () => {
); );
}); });
it("does not prompt when preparation is superseded", async () => {
const ensurePrepared = vi.fn().mockResolvedValue(false);
const { result } = renderHook(() =>
useChat("session-1", undefined, undefined, undefined, {
ensurePrepared,
}),
);
await act(async () => {
await result.current.sendMessage("Hello");
});
expect(ensurePrepared).toHaveBeenCalledTimes(1);
expect(mockAcpSendMessage).not.toHaveBeenCalled();
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(messages).toHaveLength(2);
expect(messages[0].role).toBe("user");
expect(messages[1].content).toEqual([
{
type: "systemNotification",
notificationType: "error",
text: "Session configuration changed while preparing. Try sending again.",
},
]);
expect(runtime.error).toBe(
"Session configuration changed while preparing. Try sending again.",
);
expect(runtime.chatState).toBe("idle");
expect(runtime.streamingMessageId).toBeNull();
});
it("appends an error message and removes the empty assistant placeholder when send fails", async () => { it("appends an error message and removes the empty assistant placeholder when send fails", async () => {
mockAcpSendMessage.mockRejectedValue( mockAcpSendMessage.mockRejectedValue(
new Error("Working directory missing"), new Error("Working directory missing"),
@@ -4,6 +4,7 @@ import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore"; import { useProjectStore } from "@/features/projects/stores/projectStore";
import { useChatStore } from "../../stores/chatStore"; import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore"; import { useChatSessionStore } from "../../stores/chatSessionStore";
import { applyLatestSessionConfig } from "../../lib/sessionConfigRequests";
const mockAcpPrepareSession = vi.fn(); const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn(); const mockAcpSetModel = vi.fn();
@@ -23,6 +24,16 @@ const mockPickerState = {
modelStatusMessage: null as string | null, modelStatusMessage: null as string | null,
}; };
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
vi.mock("@/shared/api/acp", () => ({ vi.mock("@/shared/api/acp", () => ({
acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args),
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
@@ -360,6 +371,82 @@ describe("useChatSessionController", () => {
}); });
}); });
it("moves pending Home queued messages when preparation is superseded", async () => {
const firstPrepare = deferred();
mockAcpPrepareSession.mockReturnValueOnce(firstPrepare.promise);
const { result, rerender } = renderHook(
({ sessionId }: { sessionId: string | null }) =>
useChatSessionController({ sessionId }),
{
initialProps: { sessionId: null as string | null },
},
);
act(() => {
result.current.handleModelChange("claude-sonnet-4");
useChatStore
.getState()
.enqueueMessage("__home_pending__", { text: "queued from Home" });
});
useChatSessionStore.setState((state) => ({
sessions: [
{
id: "session-superseded-home",
title: "Chat",
providerId: "openai",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
messageCount: 0,
},
...state.sessions,
],
}));
rerender({ sessionId: "session-superseded-home" });
await waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-superseded-home",
"anthropic",
"/tmp/project",
);
});
const latestConfig = applyLatestSessionConfig({
sessionId: "session-superseded-home",
providerId: "anthropic",
workingDir: "/tmp/other-project",
modelId: "claude-sonnet-4",
});
firstPrepare.resolve();
await waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-superseded-home",
"anthropic",
"/tmp/other-project",
);
});
await expect(latestConfig).resolves.toEqual({ applied: true });
await waitFor(() => {
expect(
useChatStore.getState().queuedMessageBySession[
"session-superseded-home"
],
).toEqual({ text: "queued from Home" });
});
expect(
useChatStore.getState().queuedMessageBySession.__home_pending__,
).toBeUndefined();
expect(
window.localStorage.getItem("goose:preferredModelsByAgent"),
).toBeNull();
});
it("does not persist or record a pending Home model when ACP rejects it", async () => { it("does not persist or record a pending Home model when ACP rejects it", async () => {
mockAcpSetModel.mockRejectedValueOnce(new Error("set model failed")); mockAcpSetModel.mockRejectedValueOnce(new Error("set model failed"));
@@ -565,7 +565,7 @@ describe("useResolvedAgentModelPicker", () => {
setPendingProviderId: vi.fn(), setPendingProviderId: vi.fn(),
setPendingModelSelection: vi.fn(), setPendingModelSelection: vi.fn(),
setGlobalSelectedProvider: vi.fn(), setGlobalSelectedProvider: vi.fn(),
prepareSelectedProvider: vi.fn(), prepareSelectedProvider: vi.fn().mockResolvedValue(true),
}), }),
); );
@@ -664,7 +664,7 @@ describe("useResolvedAgentModelPicker", () => {
setPendingProviderId: vi.fn(), setPendingProviderId: vi.fn(),
setPendingModelSelection: vi.fn(), setPendingModelSelection: vi.fn(),
setGlobalSelectedProvider: vi.fn(), setGlobalSelectedProvider: vi.fn(),
prepareSelectedProvider: vi.fn(), prepareSelectedProvider: vi.fn().mockResolvedValue(true),
}), }),
); );
@@ -688,4 +688,198 @@ describe("useResolvedAgentModelPicker", () => {
}); });
}); });
}); });
it("does not persist a superseded explicit model selection", async () => {
const prepareSelectedProvider = vi.fn().mockResolvedValue(false);
mockUseAgentModelPickerState.mockImplementation(
({
onModelSelected,
}: {
onModelSelected?: (model: {
id: string;
name: string;
displayName?: string;
providerId?: string;
}) => void;
}) => ({
pickerAgents: [{ id: "goose", label: "Goose" }],
availableModels: [
{
id: "gpt-5.4",
name: "GPT-5.4",
displayName: "GPT-5.4",
providerId: "openai",
},
],
modelsLoading: false,
modelStatusMessage: null,
handleProviderChange: vi.fn(),
handleModelChange: (modelId: string) =>
onModelSelected?.({
id: modelId,
name: "GPT-5.4",
displayName: "GPT-5.4",
providerId: "openai",
}),
}),
);
const { result } = renderHook(() =>
useResolvedAgentModelPicker({
providers: [
{ id: "goose", label: "Goose" },
{ id: "openai", label: "OpenAI" },
],
selectedProvider: "openai",
sessionId: "session-1",
session: {
id: "session-1",
title: "Chat",
providerId: "openai",
modelId: "current",
modelName: "current",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
messageCount: 0,
},
pendingModelSelection: undefined,
setPendingProviderId: vi.fn(),
setPendingModelSelection: vi.fn(),
setGlobalSelectedProvider: vi.fn(),
prepareSelectedProvider,
}),
);
act(() => {
result.current.handleModelChange("gpt-5.4");
});
await waitFor(() => {
expect(prepareSelectedProvider).toHaveBeenCalledWith("openai", {
id: "gpt-5.4",
name: "GPT-5.4",
providerId: "openai",
source: "explicit",
});
});
expect(localStorage.getItem("goose:preferredModelsByAgent")).toBeNull();
});
it("preserves persisted Claude Code / Opus during empty inventory and catalog", () => {
useProviderCatalogStore.getState().reset();
window.localStorage.setItem(
"goose:preferredModelsByAgent",
JSON.stringify({
"claude-acp": {
modelId: "opus",
modelName: "Claude Opus",
providerId: "claude-acp",
},
}),
);
mockUseProviderInventory.mockReturnValue({
getEntry: () => undefined,
});
mockUseAgentModelPickerState.mockImplementation(() => ({
pickerAgents: [{ id: "goose", label: "Goose" }],
availableModels: [],
modelsLoading: true,
modelStatusMessage: null,
handleProviderChange: vi.fn(),
handleModelChange: vi.fn(),
}));
const { result } = renderHook(() =>
useResolvedAgentModelPicker({
providers: [],
selectedProvider: "claude-acp",
sessionId: null,
session: undefined,
pendingModelSelection: undefined,
setPendingProviderId: vi.fn(),
setPendingModelSelection: vi.fn(),
setGlobalSelectedProvider: vi.fn(),
prepareSelectedProvider: vi.fn(),
}),
);
expect(result.current.selectedAgentId).toBe("claude-acp");
expect(result.current.effectiveModelSelection).toEqual({
id: "opus",
name: "Claude Opus",
providerId: "claude-acp",
source: "explicit",
});
});
it("retains selection after validated inventory confirms the agent", () => {
window.localStorage.setItem(
"goose:preferredModelsByAgent",
JSON.stringify({
"claude-acp": {
modelId: "opus",
modelName: "Claude Opus",
providerId: "claude-acp",
},
}),
);
mockUseProviderInventory.mockReturnValue({
getEntry: (id: string) =>
id === "claude-acp"
? {
providerId: "claude-acp",
category: "agent",
models: [
{ id: "opus", name: "Claude Opus", recommended: false },
{ id: "sonnet", name: "Claude Sonnet", recommended: true },
],
}
: undefined,
});
mockUseAgentModelPickerState.mockImplementation(() => ({
pickerAgents: [
{ id: "goose", label: "Goose" },
{ id: "claude-acp", label: "Claude Code" },
],
availableModels: [
{ id: "opus", name: "Claude Opus", providerId: "claude-acp" },
{ id: "sonnet", name: "Claude Sonnet", providerId: "claude-acp" },
],
modelsLoading: false,
modelStatusMessage: null,
handleProviderChange: vi.fn(),
handleModelChange: vi.fn(),
}));
const { result } = renderHook(() =>
useResolvedAgentModelPicker({
providers: [
{ id: "goose", label: "Goose" },
{ id: "claude-acp", label: "Claude Code" },
],
selectedProvider: "claude-acp",
sessionId: null,
session: undefined,
pendingModelSelection: undefined,
setPendingProviderId: vi.fn(),
setPendingModelSelection: vi.fn(),
setGlobalSelectedProvider: vi.fn(),
prepareSelectedProvider: vi.fn(),
}),
);
expect(result.current.selectedAgentId).toBe("claude-acp");
expect(result.current.effectiveModelSelection).toEqual({
id: "opus",
name: "Claude Opus",
providerId: "claude-acp",
source: "explicit",
});
});
}); });
+20 -3
View File
@@ -34,6 +34,7 @@ import type { ChatSendOptions } from "../types";
const MANUAL_COMPACT_TRIGGER = "/compact"; const MANUAL_COMPACT_TRIGGER = "/compact";
const EMPTY_MESSAGES: Message[] = []; const EMPTY_MESSAGES: Message[] = [];
type CompactConversationResult = "completed" | "failed" | "skipped"; type CompactConversationResult = "completed" | "failed" | "skipped";
type EnsurePrepared = (personaId?: string) => Promise<boolean | undefined>;
function createCompactionConfirmationMessage() { function createCompactionConfirmationMessage() {
return createSystemNotificationMessage( return createSystemNotificationMessage(
@@ -66,6 +67,16 @@ function getErrorMessage(error: unknown): string {
return "Unknown error"; return "Unknown error";
} }
async function ensurePreparedForPrompt(
ensurePrepared: EnsurePrepared | undefined,
personaId?: string,
) {
const prepared = await ensurePrepared?.(personaId);
if (prepared === false) {
throw new Error(i18n.t("chat:errors.sessionPreparationSuperseded"));
}
}
function markMessageStopped(sessionId: string, messageId: string) { function markMessageStopped(sessionId: string, messageId: string) {
useChatStore.getState().updateMessage(sessionId, messageId, (message) => { useChatStore.getState().updateMessage(sessionId, messageId, (message) => {
if ( if (
@@ -102,7 +113,7 @@ export function useChat(
personaInfo?: { id: string; name: string }, personaInfo?: { id: string; name: string },
options?: { options?: {
onMessageAccepted?: (sessionId: string) => void; onMessageAccepted?: (sessionId: string) => void;
ensurePrepared?: (personaId?: string) => Promise<void>; ensurePrepared?: EnsurePrepared;
}, },
) { ) {
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -243,7 +254,10 @@ export function useChat(
abortRef.current = abort; abortRef.current = abort;
try { try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id); await ensurePreparedForPrompt(
options?.ensurePrepared,
effectivePersonaInfo?.id,
);
setChatState(sessionId, "streaming"); setChatState(sessionId, "streaming");
const promptWithPaths = appendAttachmentPaths(text.trim(), attachments); const promptWithPaths = appendAttachmentPaths(text.trim(), attachments);
@@ -386,7 +400,10 @@ export function useChat(
setError(sessionId, null); setError(sessionId, null);
try { try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id); await ensurePreparedForPrompt(
options?.ensurePrepared,
effectivePersonaInfo?.id,
);
} catch (err) { } catch (err) {
const errorMessage = getErrorMessage(err); const errorMessage = getErrorMessage(err);
addMessage( addMessage(
@@ -3,13 +3,13 @@ import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt";
import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { skillDraftSnapshotsMatch } from "../lib/chatInputSnapshots"; import { skillDraftSnapshotsMatch } from "../lib/chatInputSnapshots";
import { submitComposerMessage } from "../lib/submitComposerMessage"; import { submitComposerMessage } from "../lib/submitComposerMessage";
import type { ChatInputProps, ChatSkillDraft } from "../types"; import type { ChatInputSendHandler, ChatSkillDraft } from "../types";
interface UseChatInputSubmitOptions { interface UseChatInputSubmitOptions {
attachmentsRef: RefObject<ChatAttachmentDraft[]>; attachmentsRef: RefObject<ChatAttachmentDraft[]>;
selectedSkillsRef: RefObject<ChatSkillDraft[]>; selectedSkillsRef: RefObject<ChatSkillDraft[]>;
selectedPersonaId?: string | null; selectedPersonaId?: string | null;
onSend: ChatInputProps["onSend"]; onSend: ChatInputSendHandler;
setSelectedSkills: (skills: ChatSkillDraft[]) => void; setSelectedSkills: (skills: ChatSkillDraft[]) => void;
resolveSkillSlashCommand: ( resolveSkillSlashCommand: (
message: string, message: string,
@@ -20,14 +20,13 @@ import {
resolveProjectDefaultArtifactRoot, resolveProjectDefaultArtifactRoot,
} from "@/features/projects/lib/chatProjectContext"; } from "@/features/projects/lib/chatProjectContext";
import { setStoredModelPreference } from "../lib/modelPreferences"; import { setStoredModelPreference } from "../lib/modelPreferences";
import { applyLatestSessionConfig } from "../lib/sessionConfigRequests";
import { import {
shouldAutoCompactContext, shouldAutoCompactContext,
supportsContextAutoCompaction, supportsContextAutoCompaction,
supportsContextCompactionControls, supportsContextCompactionControls,
} from "../lib/autoCompact"; } from "../lib/autoCompact";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import { updateSessionProject } from "../stores/chatSessionOperations";
import { import {
useResolvedAgentModelPicker, useResolvedAgentModelPicker,
type PreferredModelSelection, type PreferredModelSelection,
@@ -42,6 +41,15 @@ interface UseChatSessionControllerOptions {
const PENDING_HOME_SESSION_ID = "__home_pending__"; const PENDING_HOME_SESSION_ID = "__home_pending__";
const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = []; const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = [];
function movePendingHomeQueuedMessage(sessionId: string) {
const chatState = useChatStore.getState();
const pendingQueue =
chatState.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null;
if (pendingQueue && !chatState.queuedMessageBySession[sessionId]) {
chatState.enqueueMessage(sessionId, pendingQueue);
}
}
export function useChatSessionController({ export function useChatSessionController({
sessionId, sessionId,
onMessageAccepted, onMessageAccepted,
@@ -165,15 +173,20 @@ export function useChatSessionController({
modelSelection?: PreferredModelSelection | null, modelSelection?: PreferredModelSelection | null,
) => { ) => {
if (!sessionId) { if (!sessionId) {
return; return false;
} }
const workingDir = await resolveSessionCwd( const workingDir = await resolveSessionCwd(
nextProject, nextProject,
nextWorkspacePath, nextWorkspacePath,
); );
await acpPrepareSession(sessionId, providerId, workingDir); const result = await applyLatestSessionConfig({
if (!modelSelection?.id) { sessionId,
return; providerId,
workingDir,
modelId: modelSelection?.id,
});
if (!result.applied || !modelSelection?.id) {
return result.applied;
} }
const sessionStore = useChatSessionStore.getState(); const sessionStore = useChatSessionStore.getState();
@@ -183,14 +196,14 @@ export function useChatSessionController({
liveSession?.modelName === modelSelection.name; liveSession?.modelName === modelSelection.name;
if (modelAlreadyApplied) { if (modelAlreadyApplied) {
return; return true;
} }
await acpSetModel(sessionId, modelSelection.id);
sessionStore.patchSession(sessionId, { sessionStore.patchSession(sessionId, {
modelId: modelSelection.id, modelId: modelSelection.id,
modelName: modelSelection.name, modelName: modelSelection.name,
}); });
return true;
}, },
[activeWorkspace?.path, project, sessionId], [activeWorkspace?.path, project, sessionId],
); );
@@ -318,18 +331,16 @@ export function useChatSessionController({
.projects.find((candidate) => candidate.id === projectId) ?? .projects.find((candidate) => candidate.id === projectId) ??
null); null);
void (async () => { useChatSessionStore.getState().patchSession(sessionId, { projectId });
await updateSessionProject(sessionId, projectId); if (!selectedProvider) {
if (!selectedProvider) { return;
return; }
} void prepareCurrentSession(
await prepareCurrentSession( selectedProvider,
selectedProvider, nextProject,
nextProject, activeWorkspace?.path,
activeWorkspace?.path, effectiveModelSelection,
effectiveModelSelection, ).catch((error) => {
);
})().catch((error) => {
console.error("Failed to update ACP session working directory:", error); console.error("Failed to update ACP session working directory:", error);
}); });
}, },
@@ -431,6 +442,7 @@ export function useChatSessionController({
selectedProvider, selectedProvider,
project, project,
activeWorkspace?.path, activeWorkspace?.path,
effectiveModelSelection,
) )
: undefined, : undefined,
}, },
@@ -710,6 +722,7 @@ export function useChatSessionController({
personaId?: string | undefined; personaId?: string | undefined;
modelId?: string | undefined; modelId?: string | undefined;
modelName?: string | undefined; modelName?: string | undefined;
projectId?: string | null;
} = {}; } = {};
if (hasPendingProvider) { if (hasPendingProvider) {
@@ -720,15 +733,14 @@ export function useChatSessionController({
if (hasPendingPersona) { if (hasPendingPersona) {
patch.personaId = nextPersonaId; patch.personaId = nextPersonaId;
} }
if (Object.keys(patch).length > 0) { if (hasPendingProject) {
useChatSessionStore.getState().patchSession(sessionId, patch); patch.projectId = nextProjectId ?? null;
} }
useChatSessionStore.getState().patchSession(sessionId, patch);
try { try {
if (hasPendingProject) { const applied = await prepareCurrentSession(
await updateSessionProject(sessionId, nextProjectId ?? null);
}
await prepareCurrentSession(
nextProviderId, nextProviderId,
nextProject, nextProject,
activeWorkspace?.path, activeWorkspace?.path,
@@ -737,7 +749,7 @@ export function useChatSessionController({
if (cancelled) { if (cancelled) {
return; return;
} }
if (pendingModelSelection?.source === "explicit") { if (applied && pendingModelSelection?.source === "explicit") {
const agentId = const agentId =
resolveAgentProviderCatalogIdStrictFromEntries( resolveAgentProviderCatalogIdStrictFromEntries(
catalogEntries, catalogEntries,
@@ -760,16 +772,7 @@ export function useChatSessionController({
setPendingModelSelection(undefined); setPendingModelSelection(undefined);
} }
const latestChatState = useChatStore.getState(); movePendingHomeQueuedMessage(sessionId);
const latestPendingQueue =
latestChatState.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null;
if (
latestPendingQueue &&
!latestChatState.queuedMessageBySession[sessionId]
) {
latestChatState.enqueueMessage(sessionId, latestPendingQueue);
}
useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID); useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID);
useChatStore.getState().clearSkillDrafts(PENDING_HOME_SESSION_ID); useChatStore.getState().clearSkillDrafts(PENDING_HOME_SESSION_ID);
useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID); useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID);
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { AcpProvider } from "@/shared/api/acp"; import type { AcpProvider } from "@/shared/api/acp";
import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory";
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
import { getClient } from "@/shared/api/acpConnection"; import { getClient } from "@/shared/api/acpConnection";
import { acpSetModel } from "@/shared/api/acp";
import { import {
useChatSessionStore, useChatSessionStore,
type ChatSession, type ChatSession,
@@ -40,7 +39,7 @@ interface UseResolvedAgentModelPickerOptions {
prepareSelectedProvider: ( prepareSelectedProvider: (
providerId: string, providerId: string,
modelSelection?: PreferredModelSelection | null, modelSelection?: PreferredModelSelection | null,
) => Promise<void>; ) => Promise<boolean>;
} }
function isModelAlias(modelId?: string | null): boolean { function isModelAlias(modelId?: string | null): boolean {
@@ -61,6 +60,12 @@ export function useResolvedAgentModelPicker({
const catalogEntries = useProviderCatalogStore((state) => state.entries); const catalogEntries = useProviderCatalogStore((state) => state.entries);
const catalogLoaded = useProviderCatalogStore((state) => state.loaded); const catalogLoaded = useProviderCatalogStore((state) => state.loaded);
const { getEntry: getProviderInventoryEntry } = useProviderInventory(); const { getEntry: getProviderInventoryEntry } = useProviderInventory();
// Monotonic version counter shared across onProviderSelected and
// onModelSelected. Any user interaction (provider OR model change) bumps
// this, which invalidates in-flight async work from either callback —
// intentionally cross-callback so a rapid provider switch also cancels a
// stale model mutation and vice versa.
const selectionVersionRef = useRef(0);
const [gooseDefaultSelection, setGooseDefaultSelection] = const [gooseDefaultSelection, setGooseDefaultSelection] =
useState<PreferredModelSelection | null>(null); useState<PreferredModelSelection | null>(null);
@@ -212,6 +217,8 @@ export function useResolvedAgentModelPicker({
providers, providers,
selectedProvider, selectedProvider,
onProviderSelected: (providerId) => { onProviderSelected: (providerId) => {
selectionVersionRef.current += 1;
const versionAtSelection = selectionVersionRef.current;
const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries(
catalogEntries, catalogEntries,
providerId, providerId,
@@ -259,6 +266,9 @@ export function useResolvedAgentModelPicker({
setGlobalSelectedProvider(nextProviderId); setGlobalSelectedProvider(nextProviderId);
void prepareSelectedProvider(nextProviderId, nextModelSelection).catch( void prepareSelectedProvider(nextProviderId, nextModelSelection).catch(
(error) => { (error) => {
if (selectionVersionRef.current !== versionAtSelection) {
return;
}
console.error("Failed to update ACP session provider:", error); console.error("Failed to update ACP session provider:", error);
}, },
); );
@@ -267,6 +277,12 @@ export function useResolvedAgentModelPicker({
const modelId = model.id; const modelId = model.id;
const modelName = model.displayName ?? model.name ?? model.id; const modelName = model.displayName ?? model.name ?? model.id;
const nextProviderId = model.providerId ?? selectedProvider; const nextProviderId = model.providerId ?? selectedProvider;
const nextModelSelection: PreferredModelSelection = {
id: modelId,
name: modelName,
providerId: nextProviderId,
source: "explicit",
};
const nextStoredModelPreference = { const nextStoredModelPreference = {
modelId, modelId,
modelName, modelName,
@@ -278,15 +294,14 @@ export function useResolvedAgentModelPicker({
setPendingProviderId(nextProviderId); setPendingProviderId(nextProviderId);
setGlobalSelectedProvider(nextProviderId); setGlobalSelectedProvider(nextProviderId);
} }
setPendingModelSelection({ setPendingModelSelection(nextModelSelection);
id: modelId,
name: modelName,
providerId: nextProviderId,
source: "explicit",
});
return; return;
} }
// No-op guard: if the selected model/provider already matches the
// session, bail out without bumping the version counter. Bumping
// before this check would invalidate in-flight async work from the
// original selection that is still correctly configuring the backend.
if ( if (
!session || !session ||
(modelId === session.modelId && (modelId === session.modelId &&
@@ -295,6 +310,9 @@ export function useResolvedAgentModelPicker({
return; return;
} }
selectionVersionRef.current += 1;
const versionAtSelection = selectionVersionRef.current;
const previousStoredModelPreference = const previousStoredModelPreference =
getStoredModelPreference(selectedAgentId); getStoredModelPreference(selectedAgentId);
const previousProviderId = session.providerId; const previousProviderId = session.providerId;
@@ -317,12 +335,18 @@ export function useResolvedAgentModelPicker({
void (async () => { void (async () => {
try { try {
if (providerChanged && nextProviderId) { const applied = await prepareSelectedProvider(
await prepareSelectedProvider(nextProviderId); nextProviderId,
nextModelSelection,
);
if (!applied || selectionVersionRef.current !== versionAtSelection) {
return;
} }
await acpSetModel(sessionId, modelId);
setStoredModelPreference(selectedAgentId, nextStoredModelPreference); setStoredModelPreference(selectedAgentId, nextStoredModelPreference);
} catch (error) { } catch (error) {
if (selectionVersionRef.current !== versionAtSelection) {
return;
}
console.error("Failed to set model:", error); console.error("Failed to set model:", error);
if (providerChanged && previousProviderId) { if (providerChanged && previousProviderId) {
setGlobalSelectedProvider(previousProviderId); setGlobalSelectedProvider(previousProviderId);
@@ -342,11 +366,18 @@ export function useResolvedAgentModelPicker({
}); });
void (async () => { void (async () => {
try { try {
if (providerChanged && previousProviderId) { if (previousProviderId) {
await prepareSelectedProvider(previousProviderId); await prepareSelectedProvider(
} previousProviderId,
if (previousModelId) { previousModelId
await acpSetModel(sessionId, previousModelId); ? {
id: previousModelId,
name: previousModelName ?? previousModelId,
providerId: previousProviderId,
source: "explicit",
}
: null,
);
} }
} catch (rollbackError) { } catch (rollbackError) {
console.error( console.error(
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { resolveSelectedAgentId } from "../agentProviderResolution";
import type { ProviderCatalogEntry } from "@/shared/types/providers";
const catalogEntries: ProviderCatalogEntry[] = [
{
id: "claude-acp",
displayName: "Claude Code",
category: "agent",
description: "Claude Code",
setupMethod: "cli_auth",
group: "default",
aliases: ["claude-acp", "claude_code", "claude"],
},
{
id: "openai",
displayName: "OpenAI",
category: "model",
description: "OpenAI",
setupMethod: "single_api_key",
group: "default",
},
];
const noInventory = () => undefined;
describe("resolveSelectedAgentId", () => {
it("returns goose when no provider is selected", () => {
expect(
resolveSelectedAgentId({
catalogEntries,
catalogLoaded: true,
selectedProvider: undefined,
getProviderInventoryEntry: noInventory,
}),
).toBe("goose");
});
it("resolves known agent from catalog", () => {
expect(
resolveSelectedAgentId({
catalogEntries,
catalogLoaded: true,
selectedProvider: "claude-acp",
getProviderInventoryEntry: noInventory,
}),
).toBe("claude-acp");
});
it("returns goose for model providers with catalog loaded", () => {
expect(
resolveSelectedAgentId({
catalogEntries,
catalogLoaded: true,
selectedProvider: "openai",
getProviderInventoryEntry: noInventory,
}),
).toBe("goose");
});
it("preserves persisted claude-acp during empty inventory before catalog loads", () => {
expect(
resolveSelectedAgentId({
catalogEntries: [],
catalogLoaded: false,
selectedProvider: "claude-acp",
getProviderInventoryEntry: noInventory,
}),
).toBe("claude-acp");
});
it("preserves unknown provider before catalog loads when inventory is empty", () => {
expect(
resolveSelectedAgentId({
catalogEntries: [],
catalogLoaded: false,
selectedProvider: "some-future-agent",
getProviderInventoryEntry: noInventory,
}),
).toBe("some-future-agent");
});
it("falls back to goose for model provider identified by inventory before catalog", () => {
const getEntry = (id: string) =>
id === "openai"
? ({
providerId: "openai",
category: "model" as const,
configured: true,
refreshing: false,
models: [],
} as never)
: undefined;
expect(
resolveSelectedAgentId({
catalogEntries: [],
catalogLoaded: false,
selectedProvider: "openai",
getProviderInventoryEntry: getEntry,
}),
).toBe("goose");
});
it("preserves agent provider identified by inventory before catalog", () => {
const getEntry = (id: string) =>
id === "claude-acp"
? ({
providerId: "claude-acp",
category: "agent" as const,
configured: true,
refreshing: false,
models: [],
} as never)
: undefined;
expect(
resolveSelectedAgentId({
catalogEntries: [],
catalogLoaded: false,
selectedProvider: "claude-acp",
getProviderInventoryEntry: getEntry,
}),
).toBe("claude-acp");
});
it("falls back to goose after catalog validates provider as non-agent", () => {
expect(
resolveSelectedAgentId({
catalogEntries,
catalogLoaded: true,
selectedProvider: "openai",
getProviderInventoryEntry: noInventory,
}),
).toBe("goose");
});
it("falls back to goose after catalog validates unknown provider", () => {
expect(
resolveSelectedAgentId({
catalogEntries,
catalogLoaded: true,
selectedProvider: "nonexistent-provider",
getProviderInventoryEntry: noInventory,
}),
).toBe("goose");
});
});
@@ -34,6 +34,13 @@ export function resolveSelectedAgentId({
if (inventoryEntry?.category === "agent") { if (inventoryEntry?.category === "agent") {
return selectedProvider; return selectedProvider;
} }
// Catalog not loaded and no inventory info — preserve the stored
// selection so the UI doesn't briefly flash "Goose" before validation
// completes. Fall back to "goose" only when there is no selection or
// after the catalog has loaded and proven the provider is not an agent.
if (!inventoryEntry) {
return selectedProvider;
}
} }
return "goose"; return "goose";
@@ -0,0 +1,88 @@
import type { ModelOption } from "../types";
interface ModelDisplayLabelOptions {
currentModelId?: string | null;
currentModelName?: string | null;
currentModelProviderId?: string | null;
availableModels?: ModelOption[];
}
interface PickerTriggerLabelOptions extends ModelDisplayLabelOptions {
selectedAgentLabel?: string | null;
}
function normalizeLabel(label?: string | null) {
const trimmed = label?.trim();
return trimmed ? trimmed : null;
}
function getModelDisplayName(model: ModelOption) {
return normalizeLabel(model.displayName) ?? normalizeLabel(model.name);
}
function findSelectedInventoryModel({
currentModelId,
currentModelProviderId,
availableModels = [],
}: Pick<
ModelDisplayLabelOptions,
"currentModelId" | "currentModelProviderId" | "availableModels"
>) {
const selectedModelId = normalizeLabel(currentModelId);
if (!selectedModelId) {
return null;
}
const matches = availableModels.filter(
(model) => model.id === selectedModelId,
);
if (matches.length === 0) {
return null;
}
if (currentModelProviderId) {
return (
matches.find((model) => model.providerId === currentModelProviderId) ??
matches.find((model) => !model.providerId) ??
null
);
}
return matches[0] ?? null;
}
export function resolveDisplayModelLabel({
currentModelId,
currentModelName,
currentModelProviderId,
availableModels = [],
}: ModelDisplayLabelOptions) {
const inventoryModel = findSelectedInventoryModel({
currentModelId,
currentModelProviderId,
availableModels,
});
const inventoryLabel = inventoryModel
? getModelDisplayName(inventoryModel)
: null;
if (inventoryLabel) {
return inventoryLabel;
}
const selectedModelId = normalizeLabel(currentModelId);
const modelName = normalizeLabel(currentModelName);
if (modelName && modelName !== selectedModelId) {
return modelName;
}
return null;
}
export function resolvePickerTriggerLabel({
selectedAgentLabel,
...modelOptions
}: PickerTriggerLabelOptions) {
return (
resolveDisplayModelLabel(modelOptions) ?? normalizeLabel(selectedAgentLabel)
);
}
@@ -63,6 +63,11 @@ export function getStoredModelPreference(
export function getStoredModelPreferenceForProvider( export function getStoredModelPreferenceForProvider(
providerId: string, providerId: string,
): StoredModelPreference | null { ): StoredModelPreference | null {
const exactPreference = getStoredModelPreference(providerId);
if (exactPreference) {
return exactPreference;
}
const agentId = resolveAgentProviderCatalogIdStrict(providerId) ?? "goose"; const agentId = resolveAgentProviderCatalogIdStrict(providerId) ?? "goose";
return getStoredModelPreference(agentId); return getStoredModelPreference(agentId);
} }
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { applyLatestSessionConfig } from "./sessionConfigRequests";
const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args),
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
}));
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("applyLatestSessionConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAcpPrepareSession.mockResolvedValue(undefined);
mockAcpSetModel.mockResolvedValue(undefined);
});
it("replays the latest provider and model after a stale request finishes", async () => {
const oldPrepare = deferred();
const oldSetModel = deferred();
const newPrepare = deferred();
const newSetModel = deferred();
mockAcpPrepareSession.mockImplementation(
(_sessionId: string, providerId: string) =>
providerId === "old-provider" ? oldPrepare.promise : newPrepare.promise,
);
mockAcpSetModel.mockImplementation((_sessionId: string, modelId: string) =>
modelId === "old-model" ? oldSetModel.promise : newSetModel.promise,
);
const oldResult = applyLatestSessionConfig({
sessionId: "session-latest",
providerId: "old-provider",
workingDir: "/old",
modelId: "old-model",
});
const newResult = applyLatestSessionConfig({
sessionId: "session-latest",
providerId: "new-provider",
workingDir: "/new",
modelId: "new-model",
});
await vi.waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-latest",
"old-provider",
"/old",
);
});
oldPrepare.resolve();
await vi.waitFor(() => {
expect(mockAcpSetModel).toHaveBeenCalledWith(
"session-latest",
"old-model",
);
});
oldSetModel.resolve();
await vi.waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-latest",
"new-provider",
"/new",
);
});
newPrepare.resolve();
await vi.waitFor(() => {
expect(mockAcpSetModel).toHaveBeenCalledWith(
"session-latest",
"new-model",
);
});
newSetModel.resolve();
await expect(oldResult).resolves.toEqual({ applied: false });
await expect(newResult).resolves.toEqual({ applied: true });
});
it("continues to the latest request when a stale request fails", async () => {
const oldPrepare = deferred();
const newPrepare = deferred();
const newSetModel = deferred();
mockAcpPrepareSession.mockImplementation(
(_sessionId: string, providerId: string) =>
providerId === "old-provider" ? oldPrepare.promise : newPrepare.promise,
);
mockAcpSetModel.mockReturnValue(newSetModel.promise);
const oldResult = applyLatestSessionConfig({
sessionId: "session-stale-failure",
providerId: "old-provider",
workingDir: "/old",
modelId: "old-model",
});
const newResult = applyLatestSessionConfig({
sessionId: "session-stale-failure",
providerId: "new-provider",
workingDir: "/new",
modelId: "new-model",
});
oldPrepare.reject(new Error("old prepare failed"));
await vi.waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-stale-failure",
"new-provider",
"/new",
);
});
newPrepare.resolve();
await vi.waitFor(() => {
expect(mockAcpSetModel).toHaveBeenCalledWith(
"session-stale-failure",
"new-model",
);
});
newSetModel.resolve();
await expect(oldResult).resolves.toEqual({ applied: false });
await expect(newResult).resolves.toEqual({ applied: true });
});
it("treats superseded requests as applied when the full session config matches", async () => {
const firstPrepare = deferred();
const firstSetModel = deferred();
// First call gets deferred promises; subsequent calls resolve immediately
mockAcpPrepareSession.mockReturnValueOnce(firstPrepare.promise);
mockAcpSetModel.mockReturnValueOnce(firstSetModel.promise);
// Both requests have identical config (provider, workingDir, model)
const oldResult = applyLatestSessionConfig({
sessionId: "session-same-config",
providerId: "openai",
workingDir: "/project",
modelId: "gpt-5.4",
});
const newResult = applyLatestSessionConfig({
sessionId: "session-same-config",
providerId: "openai",
workingDir: "/project",
modelId: "gpt-5.4",
});
// The queue executes the first request's prepare (stale)
await vi.waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-same-config",
"openai",
"/project",
);
});
firstPrepare.resolve();
// Then the first request's setModel
await vi.waitFor(() => {
expect(mockAcpSetModel).toHaveBeenCalledWith(
"session-same-config",
"gpt-5.4",
);
});
firstSetModel.resolve();
// After the stale request finishes, the queue replays the latest
// (which has the same config and resolves immediately via default mock)
// Both resolve as applied since the final config matches both requests
await expect(oldResult).resolves.toEqual({ applied: true });
await expect(newResult).resolves.toEqual({ applied: true });
});
it("treats superseded requests as not applied when workingDir differs", async () => {
const firstPrepare = deferred();
const firstSetModel = deferred();
// First call gets deferred promises; subsequent calls resolve immediately
mockAcpPrepareSession.mockReturnValueOnce(firstPrepare.promise);
mockAcpSetModel.mockReturnValueOnce(firstSetModel.promise);
const oldResult = applyLatestSessionConfig({
sessionId: "session-diff-dir",
providerId: "openai",
workingDir: "/old",
modelId: "gpt-5.4",
});
const newResult = applyLatestSessionConfig({
sessionId: "session-diff-dir",
providerId: "openai",
workingDir: "/new",
modelId: "gpt-5.4",
});
// First request executes (stale) with workingDir "/old"
await vi.waitFor(() => {
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-diff-dir",
"openai",
"/old",
);
});
firstPrepare.resolve();
await vi.waitFor(() => {
expect(mockAcpSetModel).toHaveBeenCalledWith(
"session-diff-dir",
"gpt-5.4",
);
});
firstSetModel.resolve();
// After the stale request finishes, the queue replays the latest
// with workingDir "/new" (resolves immediately via default mock)
// Old request is not applied (workingDir differs), new one is applied
await expect(oldResult).resolves.toEqual({ applied: false });
await expect(newResult).resolves.toEqual({ applied: true });
});
});
@@ -0,0 +1,168 @@
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
export interface SessionConfigRequest {
sessionId: string;
providerId: string;
workingDir: string;
modelId?: string | null;
}
export interface SessionConfigResult {
applied: boolean;
}
interface QueuedSessionConfigRequest extends SessionConfigRequest {
sequence: number;
}
interface SessionConfigWaiter {
sequence: number;
request: QueuedSessionConfigRequest;
resolve: (result: SessionConfigResult) => void;
reject: (error: unknown) => void;
}
interface SessionConfigQueue {
latest: QueuedSessionConfigRequest | null;
nextSequence: number;
running: boolean;
waiters: SessionConfigWaiter[];
}
const queues = new Map<string, SessionConfigQueue>();
function getQueue(sessionId: string): SessionConfigQueue {
let queue = queues.get(sessionId);
if (!queue) {
queue = {
latest: null,
nextSequence: 0,
running: false,
waiters: [],
};
queues.set(sessionId, queue);
}
return queue;
}
function sameSessionConfig(
a: SessionConfigRequest,
b: SessionConfigRequest,
): boolean {
return (
a.providerId === b.providerId &&
a.workingDir === b.workingDir &&
(a.modelId ?? null) === (b.modelId ?? null)
);
}
function settleFailedWaiters(
queue: SessionConfigQueue,
sequence: number,
error: unknown,
) {
const remaining: SessionConfigWaiter[] = [];
for (const waiter of queue.waiters) {
if (waiter.sequence > sequence) {
remaining.push(waiter);
continue;
}
if (waiter.sequence === sequence) {
waiter.reject(error);
} else {
waiter.resolve({ applied: false });
}
}
queue.waiters = remaining;
}
function settleAppliedWaiters(
queue: SessionConfigQueue,
request: QueuedSessionConfigRequest,
) {
const remaining: SessionConfigWaiter[] = [];
for (const waiter of queue.waiters) {
if (waiter.sequence > request.sequence) {
remaining.push(waiter);
continue;
}
waiter.resolve({
applied: sameSessionConfig(waiter.request, request),
});
}
queue.waiters = remaining;
}
async function applyRequest(request: QueuedSessionConfigRequest) {
await acpPrepareSession(
request.sessionId,
request.providerId,
request.workingDir,
);
if (request.modelId) {
await acpSetModel(request.sessionId, request.modelId);
}
}
async function drainQueue(sessionId: string, queue: SessionConfigQueue) {
if (queue.running) {
return;
}
queue.running = true;
try {
while (queue.latest) {
const request = queue.latest;
try {
await applyRequest(request);
} catch (error) {
if (queue.latest?.sequence !== request.sequence) {
continue;
}
queue.latest = null;
settleFailedWaiters(queue, request.sequence, error);
break;
}
if (queue.latest?.sequence !== request.sequence) {
continue;
}
queue.latest = null;
settleAppliedWaiters(queue, request);
break;
}
} finally {
queue.running = false;
if (queue.latest) {
void drainQueue(sessionId, queue);
} else if (queue.waiters.length === 0) {
queues.delete(sessionId);
}
}
}
export function applyLatestSessionConfig(
request: SessionConfigRequest,
): Promise<SessionConfigResult> {
const queue = getQueue(request.sessionId);
const sequence = queue.nextSequence + 1;
queue.nextSequence = sequence;
const queuedRequest = { ...request, sequence };
queue.latest = queuedRequest;
const result = new Promise<SessionConfigResult>((resolve, reject) => {
queue.waiters.push({
sequence,
request: queuedRequest,
resolve,
reject,
});
});
void drainQueue(request.sessionId, queue);
return result;
}
@@ -1,7 +1,7 @@
import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt"; import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt";
import { isPromiseLike } from "@/shared/lib/isPromiseLike"; import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatInputProps, ChatSkillDraft } from "../types"; import type { ChatInputSendHandler, ChatSkillDraft } from "../types";
import { buildSkillSendPayload } from "./skillSendPayload"; import { buildSkillSendPayload } from "./skillSendPayload";
interface SubmitComposerMessageOptions { interface SubmitComposerMessageOptions {
@@ -9,7 +9,7 @@ interface SubmitComposerMessageOptions {
attachments: ChatAttachmentDraft[]; attachments: ChatAttachmentDraft[];
skills: ChatSkillDraft[]; skills: ChatSkillDraft[];
selectedPersonaId?: string | null; selectedPersonaId?: string | null;
onSend: ChatInputProps["onSend"]; onSend: ChatInputSendHandler;
resolveSkillSlashCommand: ( resolveSkillSlashCommand: (
message: string, message: string,
) => SkillCommandMatch<ChatSkillDraft> | null; ) => SkillCommandMatch<ChatSkillDraft> | null;
+34 -12
View File
@@ -35,26 +35,29 @@ export interface ChatSendOptions {
chips?: MessageChip[]; chips?: MessageChip[];
} }
export interface ChatInputProps { export type ChatInputSendHandler = (
onSend: ( text: string,
text: string, personaId?: string,
personaId?: string, attachments?: ChatAttachmentDraft[],
attachments?: ChatAttachmentDraft[], options?: ChatSendOptions,
options?: ChatSendOptions, ) => boolean | Promise<boolean>;
) => boolean | Promise<boolean>;
export interface ChatInputComposerActions {
onSend: ChatInputSendHandler;
onStop?: () => void; onStop?: () => void;
isStreaming?: boolean; isStreaming?: boolean;
disabled?: boolean; disabled?: boolean;
queuedMessage?: { text: string } | null; queuedMessage?: { text: string } | null;
onDismissQueue?: () => void; onDismissQueue?: () => void;
initialValue?: string; }
onDraftChange?: (text: string) => void;
selectedSkills?: ChatSkillDraft[]; export interface ChatInputPersonaPicker {
onSkillsChange?: (skills: ChatSkillDraft[]) => void;
className?: string;
personas?: Persona[]; personas?: Persona[];
selectedPersonaId?: string | null; selectedPersonaId?: string | null;
onPersonaChange?: (personaId: string | null) => void; onPersonaChange?: (personaId: string | null) => void;
}
export interface ChatInputAgentModelPicker {
providers?: AcpProvider[]; providers?: AcpProvider[];
providersLoading?: boolean; providersLoading?: boolean;
selectedProvider?: string; selectedProvider?: string;
@@ -67,12 +70,18 @@ export interface ChatInputProps {
modelStatusMessage?: string | null; modelStatusMessage?: string | null;
onModelChange?: (modelId: string, model?: ModelOption) => void; onModelChange?: (modelId: string, model?: ModelOption) => void;
onPickerOpen?: () => void; onPickerOpen?: () => void;
}
export interface ChatInputProjectPicker {
selectedProjectId?: string | null; selectedProjectId?: string | null;
availableProjects?: ProjectOption[]; availableProjects?: ProjectOption[];
onProjectChange?: (projectId: string | null) => void; onProjectChange?: (projectId: string | null) => void;
onCreateProject?: (options?: { onCreateProject?: (options?: {
onCreated?: (projectId: string) => void; onCreated?: (projectId: string) => void;
}) => void; }) => void;
}
export interface ChatInputContextUsage {
contextTokens?: number; contextTokens?: number;
contextLimit?: number; contextLimit?: number;
isContextUsageReady?: boolean; isContextUsageReady?: boolean;
@@ -81,3 +90,16 @@ export interface ChatInputProps {
isCompactingContext?: boolean; isCompactingContext?: boolean;
supportsCompactionControls?: boolean; supportsCompactionControls?: boolean;
} }
export interface ChatInputProps {
composerActions: ChatInputComposerActions;
initialValue?: string;
onDraftChange?: (text: string) => void;
selectedSkills?: ChatSkillDraft[];
onSkillsChange?: (skills: ChatSkillDraft[]) => void;
className?: string;
personaPicker?: ChatInputPersonaPicker;
agentModelPicker?: ChatInputAgentModelPicker;
projectPicker?: ChatInputProjectPicker;
contextUsage?: ChatInputContextUsage;
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { AcpProvider } from "@/shared/api/acp"; import type { AcpProvider } from "@/shared/api/acp";
@@ -11,6 +11,10 @@ import {
formatProviderLabel, formatProviderLabel,
getProviderIcon, getProviderIcon,
} from "@/shared/ui/icons/ProviderIcons"; } from "@/shared/ui/icons/ProviderIcons";
import {
resolveDisplayModelLabel,
resolvePickerTriggerLabel,
} from "../lib/modelDisplayLabel";
import type { ModelOption } from "../types"; import type { ModelOption } from "../types";
import { AllModelsList, RecommendedModelList } from "./AgentModelPickerLists"; import { AllModelsList, RecommendedModelList } from "./AgentModelPickerLists";
import { PickerItem } from "./AgentModelPickerItem"; import { PickerItem } from "./AgentModelPickerItem";
@@ -52,16 +56,26 @@ export function AgentModelPicker({
}: AgentModelPickerProps) { }: AgentModelPickerProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [modelView, setModelView] = useState<ModelView>("recommended"); const [modelView, setModelView] = useState<ModelView>("recommended");
const selectedAgentLabel = const selectedAgentLabel =
agents.find((agent) => agent.id === selectedAgentId)?.label ?? agents.find((agent) => agent.id === selectedAgentId)?.label ??
formatProviderLabel(selectedAgentId); formatProviderLabel(selectedAgentId);
const hasSelectedModel = const displayModelLabel = resolveDisplayModelLabel({
showSelectedModelInTrigger && currentModelId,
(currentModelName !== null || currentModelId !== null); currentModelName,
const triggerModelLabel = hasSelectedModel currentModelProviderId,
? (currentModelName ?? currentModelId) availableModels,
: null; });
const triggerLabel = showSelectedModelInTrigger
? resolvePickerTriggerLabel({
currentModelId,
currentModelName,
currentModelProviderId,
availableModels,
selectedAgentLabel,
})
: selectedAgentLabel;
const handleAgentSelect = (agentId: string) => { const handleAgentSelect = (agentId: string) => {
if (agentId !== selectedAgentId) { if (agentId !== selectedAgentId) {
@@ -105,15 +119,22 @@ export function AgentModelPicker({
className="min-w-0 max-w-full" className="min-w-0 max-w-full"
> >
<span className={cn("truncate", isCompact ? "max-w-32" : "max-w-56")}> <span className={cn("truncate", isCompact ? "max-w-32" : "max-w-56")}>
{triggerModelLabel ?? {triggerLabel ?? (loading ? t("toolbar.loading") : null)}
selectedAgentLabel ??
(loading ? t("toolbar.loading") : null)}
</span> </span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
ref={contentRef}
align="start" align="start"
className="h-[min(24rem,50vh)] w-96 overflow-hidden p-1" className="h-[min(24rem,50vh)] w-96 overflow-hidden p-1"
onOpenAutoFocus={(e) => {
e.preventDefault();
contentRef.current
?.querySelector<HTMLElement>(
'[data-col="agent"] button[data-selected]',
)
?.focus();
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
@@ -192,6 +213,7 @@ export function AgentModelPicker({
key={agent.id} key={agent.id}
onClick={() => handleAgentSelect(agent.id)} onClick={() => handleAgentSelect(agent.id)}
selected={isSelected} selected={isSelected}
data-selected={isSelected || undefined}
> >
{agentIcon ? ( {agentIcon ? (
<span className="shrink-0">{agentIcon}</span> <span className="shrink-0">{agentIcon}</span>
@@ -221,11 +243,11 @@ export function AgentModelPicker({
<div className="shrink-0 px-2 py-1.5 text-sm font-semibold"> <div className="shrink-0 px-2 py-1.5 text-sm font-semibold">
{t("toolbar.model")} {t("toolbar.model")}
</div> </div>
{currentModelName || currentModelId ? ( {displayModelLabel ? (
<div className="space-y-0.5 p-1"> <div className="space-y-0.5 p-1">
<PickerItem selected disabled> <PickerItem selected disabled>
<div className="min-w-0 flex-1 truncate"> <div className="min-w-0 flex-1 truncate">
{currentModelName ?? currentModelId} {displayModelLabel}
</div> </div>
<Spinner className="size-3.5 shrink-0" /> <Spinner className="size-3.5 shrink-0" />
</PickerItem> </PickerItem>
@@ -267,7 +289,7 @@ export function AgentModelPicker({
<div className="px-2 py-2"> <div className="px-2 py-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{modelStatusMessage ?? {modelStatusMessage ??
currentModelName ?? displayModelLabel ??
t("toolbar.noModelsAvailable")} t("toolbar.noModelsAvailable")}
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
export function PickerItem({ export function PickerItem({
@@ -7,13 +7,11 @@ export function PickerItem({
selected = false, selected = false,
disabled = false, disabled = false,
className, className,
...rest
}: { }: {
children: ReactNode; children: ReactNode;
onClick?: () => void;
selected?: boolean; selected?: boolean;
disabled?: boolean; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type">) {
className?: string;
}) {
return ( return (
<button <button
type="button" type="button"
@@ -26,6 +24,7 @@ export function PickerItem({
selected && "bg-muted/60", selected && "bg-muted/60",
className, className,
)} )}
{...rest}
> >
{children} {children}
</button> </button>
+100 -78
View File
@@ -24,47 +24,63 @@ import { ChatInputAttachments } from "./ChatInputAttachments";
import { ChatInputSelectionChips } from "./ChatInputSelectionChips"; import { ChatInputSelectionChips } from "./ChatInputSelectionChips";
import { useChatInputSubmit } from "../hooks/useChatInputSubmit"; import { useChatInputSubmit } from "../hooks/useChatInputSubmit";
import { useVoiceDictation } from "../hooks/useVoiceDictation"; import { useVoiceDictation } from "../hooks/useVoiceDictation";
import { resolveDisplayModelLabel } from "../lib/modelDisplayLabel";
import type { ChatInputProps, ChatSkillDraft } from "../types"; import type { ChatInputProps, ChatSkillDraft } from "../types";
export function ChatInput({ export function ChatInput({
onSend, composerActions,
onStop,
isStreaming = false,
disabled = false,
queuedMessage = null,
onDismissQueue,
initialValue = "", initialValue = "",
onDraftChange, onDraftChange,
selectedSkills: selectedSkillsProp, selectedSkills: selectedSkillsProp,
onSkillsChange, onSkillsChange,
className, className,
personas = [], personaPicker,
selectedPersonaId = null, agentModelPicker,
onPersonaChange, projectPicker,
providers = [], contextUsage,
providersLoading = false,
selectedProvider = "goose",
onProviderChange,
currentModelId = null,
currentModelProviderId = null,
currentModel,
availableModels = [],
modelsLoading = false,
modelStatusMessage = null,
onModelChange,
onPickerOpen,
selectedProjectId = null,
availableProjects = [],
onProjectChange,
onCreateProject,
contextTokens = 0,
contextLimit = 0,
isContextUsageReady,
onCompactContext,
canCompactContext = false,
isCompactingContext = false,
supportsCompactionControls,
}: ChatInputProps) { }: ChatInputProps) {
const {
onSend,
onStop,
isStreaming = false,
disabled = false,
queuedMessage = null,
onDismissQueue,
} = composerActions;
const {
personas = [],
selectedPersonaId = null,
onPersonaChange,
} = personaPicker ?? {};
const {
providers = [],
providersLoading = false,
selectedProvider = "goose",
onProviderChange,
currentModelId = null,
currentModelProviderId = null,
currentModel,
availableModels = [],
modelsLoading = false,
modelStatusMessage = null,
onModelChange,
onPickerOpen,
} = agentModelPicker ?? {};
const {
selectedProjectId = null,
availableProjects = [],
onProjectChange,
onCreateProject,
} = projectPicker ?? {};
const {
contextTokens = 0,
contextLimit = 0,
isContextUsageReady,
onCompactContext,
canCompactContext = false,
isCompactingContext = false,
supportsCompactionControls,
} = contextUsage ?? {};
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue); const [text, setTextRaw] = useState(initialValue);
const [internalSelectedSkills, setInternalSelectedSkills] = useState< const [internalSelectedSkills, setInternalSelectedSkills] = useState<
@@ -339,17 +355,15 @@ export function ChatInput({
providerDisplayName, providerDisplayName,
); );
const resolvedCurrentModel = useMemo(() => { const resolvedCurrentModel = useMemo(() => {
if (currentModel) { return (
return currentModel; resolveDisplayModelLabel({
} currentModelId,
if (!currentModelId) { currentModelName: currentModel,
return undefined; currentModelProviderId,
} availableModels,
const selectedModel = availableModels.find( }) ?? undefined
(model) => model.id === currentModelId,
); );
return selectedModel?.displayName ?? selectedModel?.name ?? currentModelId; }, [availableModels, currentModel, currentModelId, currentModelProviderId]);
}, [availableModels, currentModel, currentModelId]);
const inputPlaceholder = getChatInputPlaceholder( const inputPlaceholder = getChatInputPlaceholder(
t, t,
agentDisplayName, agentDisplayName,
@@ -457,43 +471,51 @@ export function ChatInput({
</PopoverAnchor> </PopoverAnchor>
<ChatInputToolbar <ChatInputToolbar
selectedPersonaId={selectedPersonaId} personaPicker={{ selectedPersonaId }}
providers={providers} agentModelPicker={{
providersLoading={providersLoading} providers,
selectedProvider={selectedProvider} providersLoading,
onProviderChange={(id) => onProviderChange?.(id)} selectedProvider,
currentModelId={currentModelId} onProviderChange,
currentModelProviderId={currentModelProviderId} currentModelId,
currentModel={resolvedCurrentModel} currentModelProviderId,
availableModels={availableModels} currentModel: resolvedCurrentModel,
modelsLoading={modelsLoading} availableModels,
modelStatusMessage={modelStatusMessage} modelsLoading,
onModelChange={onModelChange} modelStatusMessage,
onPickerOpen={onPickerOpen} onModelChange,
selectedProjectId={selectedProjectId} onPickerOpen,
availableProjects={availableProjects} }}
onProjectChange={onProjectChange} projectPicker={{
onCreateProject={onCreateProject} selectedProjectId,
contextTokens={contextTokens} availableProjects,
contextLimit={contextLimit} onProjectChange,
isContextUsageReady={isContextUsageReady} onCreateProject,
onCompactContext={onCompactContext} }}
canCompactContext={canCompactContext} contextUsage={{
isCompactingContext={isCompactingContext} contextTokens,
supportsCompactionControls={supportsCompactionControls} contextLimit,
canSend={canSend} isContextUsageReady,
isStreaming={isStreaming} onCompactContext,
hasQueuedMessage={hasQueuedMessage} canCompactContext,
onAttachFiles={handleAttachFiles} isCompactingContext,
onAttachFolders={handleAttachFolders} supportsCompactionControls,
disabled={disabled} }}
onSend={handleSend} composerActions={{
onStop={onStop} canSend,
isStreaming,
hasQueuedMessage,
onAttachFiles: handleAttachFiles,
onAttachFolders: handleAttachFolders,
disabled,
onSend: handleSend,
onStop,
voiceEnabled: dictation.isEnabled,
voiceRecording: dictation.isRecording,
voiceTranscribing: dictation.isTranscribing,
onVoiceToggle: dictation.toggleRecording,
}}
isCompact={isCompact} isCompact={isCompact}
voiceEnabled={dictation.isEnabled}
voiceRecording={dictation.isRecording}
voiceTranscribing={dictation.isTranscribing}
onVoiceToggle={dictation.toggleRecording}
/> />
</div> </div>
</Popover> </Popover>
@@ -15,7 +15,6 @@ import type { AcpProvider } from "@/shared/api/acp";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { ChatInputSelector } from "./ChatInputSelector"; import { ChatInputSelector } from "./ChatInputSelector";
import { ContextRing } from "./ContextRing"; import { ContextRing } from "./ContextRing";
import type { ProjectOption } from "../types";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -27,49 +26,23 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Progress } from "@/shared/ui/progress"; import { Progress } from "@/shared/ui/progress";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
import { AgentModelPicker } from "./AgentModelPicker"; import { AgentModelPicker } from "./AgentModelPicker";
import type { ModelOption } from "../types";
import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog"; import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog";
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
import { supportsContextCompactionControls } from "../lib/autoCompact"; import { supportsContextCompactionControls } from "../lib/autoCompact";
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
import { ProjectSelectorIcon } from "./ProjectSelectorIcon"; import { ProjectSelectorIcon } from "./ProjectSelectorIcon";
import type {
ChatInputAgentModelPicker,
ChatInputContextUsage,
ChatInputPersonaPicker,
ChatInputProjectPicker,
} from "../types";
const NO_PROJECT_VALUE = "__no_project__"; const NO_PROJECT_VALUE = "__no_project__";
const CREATE_PROJECT_VALUE = "__create_project__"; const CREATE_PROJECT_VALUE = "__create_project__";
interface ChatInputToolbarProps { interface ChatInputToolbarComposerActions {
selectedPersonaId: string | null;
// Provider
providers: AcpProvider[];
providersLoading?: boolean;
selectedProvider: string;
onProviderChange: (providerId: string) => void;
// Model
currentModelId?: string | null;
currentModelProviderId?: string | null;
currentModel?: string;
availableModels: ModelOption[];
modelsLoading?: boolean;
modelStatusMessage?: string | null;
onModelChange?: (modelId: string, model?: ModelOption) => void;
onPickerOpen?: () => void;
// Project
selectedProjectId: string | null;
availableProjects: ProjectOption[];
onProjectChange?: (projectId: string | null) => void;
onCreateProject?: (options?: {
onCreated?: (projectId: string) => void;
}) => void;
// Context
contextTokens: number;
contextLimit: number;
isContextUsageReady?: boolean;
supportsCompactionControls?: boolean;
// Actions
canCompactContext?: boolean;
isCompactingContext?: boolean;
onCompactContext?: () => Promise<unknown> | undefined;
canSend: boolean; canSend: boolean;
isStreaming: boolean; isStreaming: boolean;
hasQueuedMessage: boolean; hasQueuedMessage: boolean;
@@ -78,58 +51,77 @@ interface ChatInputToolbarProps {
onAttachFiles?: () => void; onAttachFiles?: () => void;
onAttachFolders?: () => void; onAttachFolders?: () => void;
disabled?: boolean; disabled?: boolean;
// Voice
voiceEnabled?: boolean; voiceEnabled?: boolean;
voiceRecording?: boolean; voiceRecording?: boolean;
voiceTranscribing?: boolean; voiceTranscribing?: boolean;
onVoiceToggle?: () => void; onVoiceToggle?: () => void;
// Layout }
interface ChatInputToolbarProps {
personaPicker: Pick<ChatInputPersonaPicker, "selectedPersonaId">;
agentModelPicker: ChatInputAgentModelPicker;
projectPicker: ChatInputProjectPicker;
contextUsage: ChatInputContextUsage;
composerActions: ChatInputToolbarComposerActions;
isCompact: boolean; isCompact: boolean;
} }
export function ChatInputToolbar({ export function ChatInputToolbar({
selectedPersonaId, personaPicker,
providers, agentModelPicker,
providersLoading, projectPicker,
selectedProvider, contextUsage,
onProviderChange, composerActions,
currentModelId,
currentModelProviderId,
currentModel,
availableModels,
modelsLoading = false,
modelStatusMessage = null,
onModelChange,
onPickerOpen,
selectedProjectId,
availableProjects,
onProjectChange,
onCreateProject,
contextTokens,
contextLimit,
isContextUsageReady,
supportsCompactionControls,
canCompactContext = false,
isCompactingContext = false,
onCompactContext,
canSend,
isStreaming,
hasQueuedMessage,
onSend,
onStop,
onAttachFiles,
onAttachFolders,
disabled = false,
voiceEnabled = false,
voiceRecording = false,
voiceTranscribing = false,
onVoiceToggle,
isCompact, isCompact,
}: ChatInputToolbarProps) { }: ChatInputToolbarProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { formatNumber } = useLocaleFormatting(); const { formatNumber } = useLocaleFormatting();
const catalogEntries = useProviderCatalogStore((state) => state.entries); const catalogEntries = useProviderCatalogStore((state) => state.entries);
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false); const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
const { selectedPersonaId = null } = personaPicker;
const {
providers = [],
providersLoading,
selectedProvider = "goose",
onProviderChange,
currentModelId,
currentModelProviderId,
currentModel,
availableModels = [],
modelsLoading = false,
modelStatusMessage = null,
onModelChange,
onPickerOpen,
} = agentModelPicker;
const {
selectedProjectId = null,
availableProjects = [],
onProjectChange,
onCreateProject,
} = projectPicker;
const {
contextTokens = 0,
contextLimit = 0,
isContextUsageReady,
supportsCompactionControls,
canCompactContext = false,
isCompactingContext = false,
onCompactContext,
} = contextUsage;
const {
canSend,
isStreaming,
hasQueuedMessage,
onSend,
onStop,
onAttachFiles,
onAttachFolders,
disabled = false,
voiceEnabled = false,
voiceRecording = false,
voiceTranscribing = false,
onVoiceToggle,
} = composerActions;
const compactionControlsSupported = const compactionControlsSupported =
supportsCompactionControls ?? supportsCompactionControls ??
supportsContextCompactionControls(selectedProvider); supportsContextCompactionControls(selectedProvider);
@@ -230,7 +222,7 @@ export function ChatInputToolbar({
<AgentModelPicker <AgentModelPicker
agents={agentProviders} agents={agentProviders}
selectedAgentId={selectedProvider} selectedAgentId={selectedProvider}
onAgentChange={onProviderChange} onAgentChange={(providerId) => onProviderChange?.(providerId)}
currentModelId={currentModelId} currentModelId={currentModelId}
currentModelProviderId={currentModelProviderId} currentModelProviderId={currentModelProviderId}
currentModelName={currentModel ?? null} currentModelName={currentModel ?? null}
+52 -45
View File
@@ -125,55 +125,62 @@ export function ChatView({
<ChatInput <ChatInput
className={shouldOverlapComposer ? "-mt-4" : undefined} className={shouldOverlapComposer ? "-mt-4" : undefined}
onSend={controller.handleSend} composerActions={{
disabled={ onSend: controller.handleSend,
controller.projectMetadataPending || disabled:
controller.isCompactingContext controller.projectMetadataPending ||
} controller.isCompactingContext,
queuedMessage={controller.queue.queuedMessage} queuedMessage: controller.queue.queuedMessage,
onDismissQueue={controller.queue.dismiss} onDismissQueue: controller.queue.dismiss,
onStop: controller.stopStreaming,
isStreaming:
controller.chatState === "streaming" ||
controller.chatState === "thinking",
}}
initialValue={controller.draftValue} initialValue={controller.draftValue}
onDraftChange={controller.handleDraftChange} onDraftChange={controller.handleDraftChange}
selectedSkills={controller.selectedSkills} selectedSkills={controller.selectedSkills}
onSkillsChange={controller.handleSkillsChange} onSkillsChange={controller.handleSkillsChange}
onStop={controller.stopStreaming} personaPicker={{
isStreaming={ personas: controller.personas,
controller.chatState === "streaming" || selectedPersonaId: controller.selectedPersonaId,
controller.chatState === "thinking" onPersonaChange: controller.handlePersonaChange,
} }}
personas={controller.personas} agentModelPicker={{
selectedPersonaId={controller.selectedPersonaId} providers: controller.pickerAgents,
onPersonaChange={controller.handlePersonaChange} providersLoading: controller.providersLoading,
providers={controller.pickerAgents} selectedProvider: controller.selectedProvider,
providersLoading={controller.providersLoading} onProviderChange: controller.handleProviderChange,
selectedProvider={controller.selectedProvider} currentModelId: controller.currentModelId,
onProviderChange={controller.handleProviderChange} currentModelProviderId: controller.currentModelProviderId,
currentModelId={controller.currentModelId} currentModel: controller.currentModelName ?? undefined,
currentModelProviderId={controller.currentModelProviderId} availableModels: controller.availableModels,
currentModel={controller.currentModelName ?? undefined} modelsLoading: controller.modelsLoading,
availableModels={controller.availableModels} modelStatusMessage: controller.modelStatusMessage,
modelsLoading={controller.modelsLoading} onModelChange: controller.handleModelChange,
modelStatusMessage={controller.modelStatusMessage} onPickerOpen: controller.handlePickerOpen,
onModelChange={controller.handleModelChange} }}
onPickerOpen={controller.handlePickerOpen} projectPicker={{
selectedProjectId={controller.selectedProjectId} selectedProjectId: controller.selectedProjectId,
availableProjects={controller.availableProjects} availableProjects: controller.availableProjects,
onProjectChange={controller.handleProjectChange} onProjectChange: controller.handleProjectChange,
onCreateProject={(options) => onCreateProject: (options) =>
onCreateProject?.({ onCreateProject?.({
onCreated: (projectId) => { onCreated: (projectId) => {
controller.handleProjectChange(projectId); controller.handleProjectChange(projectId);
options?.onCreated?.(projectId); options?.onCreated?.(projectId);
}, },
}) }),
} }}
contextTokens={controller.tokenState.accumulatedTotal} contextUsage={{
contextLimit={controller.tokenState.contextLimit} contextTokens: controller.tokenState.accumulatedTotal,
isContextUsageReady={controller.isContextUsageReady} contextLimit: controller.tokenState.contextLimit,
onCompactContext={controller.compactConversation} isContextUsageReady: controller.isContextUsageReady,
canCompactContext={controller.canCompactContext} onCompactContext: controller.compactConversation,
isCompactingContext={controller.isCompactingContext} canCompactContext: controller.canCompactContext,
supportsCompactionControls={controller.supportsCompactionControls} isCompactingContext: controller.isCompactingContext,
supportsCompactionControls: controller.supportsCompactionControls,
}}
/> />
</div> </div>
@@ -14,6 +14,7 @@ globalThis.ResizeObserver ??=
const AGENTS = [ const AGENTS = [
{ id: "goose", label: "Goose" }, { id: "goose", label: "Goose" },
{ id: "claude-acp", label: "Claude Code" },
{ id: "codex-acp", label: "Codex" }, { id: "codex-acp", label: "Codex" },
]; ];
@@ -36,6 +37,108 @@ describe("AgentModelPicker", () => {
).toHaveTextContent("GPT-4o"); ).toHaveTextContent("GPT-4o");
}); });
it("uses the selected agent label while a raw model id is unresolved", () => {
render(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="claude-acp"
onAgentChange={vi.fn()}
currentModelId="opus"
currentModelProviderId="claude-acp"
currentModelName="opus"
availableModels={[]}
onModelChange={vi.fn()}
/>,
);
const trigger = screen.getByRole("button", {
name: /choose agent and model/i,
});
expect(trigger).toHaveTextContent("Claude Code");
expect(trigger).not.toHaveTextContent("opus");
});
it("uses the inventory model label for a matching raw model id", () => {
render(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="claude-acp"
onAgentChange={vi.fn()}
currentModelId="opus"
currentModelProviderId="claude-acp"
currentModelName="opus"
availableModels={[{ id: "opus", name: "Claude Opus 4.6" }]}
onModelChange={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /choose agent and model/i }),
).toHaveTextContent("Claude Opus 4.6");
});
it("uses a stored human model name before inventory resolves", () => {
render(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="claude-acp"
onAgentChange={vi.fn()}
currentModelId="opus"
currentModelProviderId="claude-acp"
currentModelName="Claude Opus 4.6"
availableModels={[]}
onModelChange={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /choose agent and model/i }),
).toHaveTextContent("Claude Opus 4.6");
});
it("allows id-as-display-name labels after inventory resolves", () => {
render(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="goose"
onAgentChange={vi.fn()}
currentModelId="gpt-5.4"
currentModelName="gpt-5.4"
availableModels={[{ id: "gpt-5.4", name: "gpt-5.4" }]}
onModelChange={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /choose agent and model/i }),
).toHaveTextContent("gpt-5.4");
});
it("does not show a raw model id in the loading row", async () => {
const user = userEvent.setup();
render(
<AgentModelPicker
agents={AGENTS}
selectedAgentId="claude-acp"
onAgentChange={vi.fn()}
currentModelId="opus"
currentModelProviderId="claude-acp"
currentModelName="opus"
availableModels={[]}
modelsLoading
onModelChange={vi.fn()}
/>,
);
await user.click(
screen.getByRole("button", { name: /choose agent and model/i }),
);
expect(screen.getByText("Loading models...")).toBeInTheDocument();
expect(screen.queryByText("opus")).not.toBeInTheDocument();
});
it("calls onModelChange when a model is selected", async () => { it("calls onModelChange when a model is selected", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onModelChange = vi.fn(); const onModelChange = vi.fn();
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ChatInput } from "../ChatInput"; import { ChatInput } from "./chatInputTestUtils";
const mockVoiceDictation = { const mockVoiceDictation = {
isEnabled: true, isEnabled: true,
@@ -7,7 +7,7 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ChatInput } from "../ChatInput"; import { ChatInput } from "./chatInputTestUtils";
vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
useAgentProviderStatus: () => ({ useAgentProviderStatus: () => ({
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ChatInput } from "../ChatInput"; import { ChatInput } from "./chatInputTestUtils";
const mockVoiceDictation = { const mockVoiceDictation = {
isEnabled: true, isEnabled: true,
@@ -2,7 +2,7 @@ import { beforeEach, describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useState } from "react"; import { useState } from "react";
import { ChatInput } from "../ChatInput"; import { ChatInput } from "./chatInputTestUtils";
import { ChatInputToolbar } from "../ChatInputToolbar"; import { ChatInputToolbar } from "../ChatInputToolbar";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import type { Persona } from "@/shared/types/agents"; import type { Persona } from "@/shared/types/agents";
@@ -167,6 +167,26 @@ describe("ChatInput", () => {
).toHaveTextContent("Goose"); ).toHaveTextContent("Goose");
}); });
it("shows provider label while the current model id is unresolved", () => {
render(
<ChatInput
onSend={vi.fn()}
currentModelId="opus"
currentModelProviderId="claude-acp"
currentModel="opus"
availableModels={[]}
providers={[{ id: "claude-acp", label: "Claude Code" }]}
selectedProvider="claude-acp"
/>,
);
const trigger = screen.getByRole("button", {
name: /choose agent and model/i,
});
expect(trigger).toHaveTextContent("Claude Code");
expect(trigger).not.toHaveTextContent("opus");
});
it("shows default provider label", () => { it("shows default provider label", () => {
render( render(
<ChatInput <ChatInput
@@ -522,22 +542,30 @@ describe("ChatInput", () => {
it("keeps the mic toggle enabled while recording even if voice input becomes unavailable", () => { it("keeps the mic toggle enabled while recording even if voice input becomes unavailable", () => {
render( render(
<ChatInputToolbar <ChatInputToolbar
selectedPersonaId={null} personaPicker={{ selectedPersonaId: null }}
providers={[]} agentModelPicker={{
selectedProvider="goose" providers: [],
onProviderChange={vi.fn()} selectedProvider: "goose",
availableModels={[]} onProviderChange: vi.fn(),
selectedProjectId={null} availableModels: [],
availableProjects={[]} }}
contextTokens={0} projectPicker={{
contextLimit={0} selectedProjectId: null,
canSend={false} availableProjects: [],
isStreaming={false} }}
hasQueuedMessage={false} contextUsage={{
onSend={vi.fn()} contextTokens: 0,
voiceEnabled={false} contextLimit: 0,
voiceRecording }}
onVoiceToggle={vi.fn()} composerActions={{
canSend: false,
isStreaming: false,
hasQueuedMessage: false,
onSend: vi.fn(),
voiceEnabled: false,
voiceRecording: true,
onVoiceToggle: vi.fn(),
}}
isCompact={false} isCompact={false}
/>, />,
); );
@@ -0,0 +1,110 @@
import { ChatInput as BaseChatInput } from "../ChatInput";
import type {
ChatInputAgentModelPicker,
ChatInputComposerActions,
ChatInputContextUsage,
ChatInputPersonaPicker,
ChatInputProjectPicker,
ChatInputProps,
ChatInputSendHandler,
} from "../../types";
type ChatInputHarnessProps = Omit<
ChatInputProps,
| "composerActions"
| "personaPicker"
| "agentModelPicker"
| "projectPicker"
| "contextUsage"
> &
Partial<ChatInputComposerActions> &
ChatInputPersonaPicker &
ChatInputAgentModelPicker &
ChatInputProjectPicker &
ChatInputContextUsage & {
onSend: ChatInputSendHandler;
};
export function ChatInput({
onSend,
onStop,
isStreaming,
disabled,
queuedMessage,
onDismissQueue,
personas,
selectedPersonaId,
onPersonaChange,
providers,
providersLoading,
selectedProvider,
onProviderChange,
currentModelId,
currentModelProviderId,
currentModel,
availableModels,
modelsLoading,
modelStatusMessage,
onModelChange,
onPickerOpen,
selectedProjectId,
availableProjects,
onProjectChange,
onCreateProject,
contextTokens,
contextLimit,
isContextUsageReady,
onCompactContext,
canCompactContext,
isCompactingContext,
supportsCompactionControls,
...props
}: ChatInputHarnessProps) {
return (
<BaseChatInput
{...props}
composerActions={{
onSend,
onStop,
isStreaming,
disabled,
queuedMessage,
onDismissQueue,
}}
personaPicker={{
personas,
selectedPersonaId,
onPersonaChange,
}}
agentModelPicker={{
providers,
providersLoading,
selectedProvider,
onProviderChange,
currentModelId,
currentModelProviderId,
currentModel,
availableModels,
modelsLoading,
modelStatusMessage,
onModelChange,
onPickerOpen,
}}
projectPicker={{
selectedProjectId,
availableProjects,
onProjectChange,
onCreateProject,
}}
contextUsage={{
contextTokens,
contextLimit,
isContextUsageReady,
onCompactContext,
canCompactContext,
isCompactingContext,
supportsCompactionControls,
}}
/>
);
}
+46 -37
View File
@@ -66,47 +66,56 @@ function HomeComposer({
return ( return (
<ChatInput <ChatInput
onSend={controller.handleSend} composerActions={{
disabled={controller.projectMetadataPending} onSend: controller.handleSend,
queuedMessage={controller.queue.queuedMessage} disabled: controller.projectMetadataPending,
onDismissQueue={controller.queue.dismiss} queuedMessage: controller.queue.queuedMessage,
onDismissQueue: controller.queue.dismiss,
onStop: controller.stopStreaming,
isStreaming:
controller.chatState === "streaming" ||
controller.chatState === "thinking",
}}
initialValue={controller.draftValue} initialValue={controller.draftValue}
onDraftChange={controller.handleDraftChange} onDraftChange={controller.handleDraftChange}
selectedSkills={controller.selectedSkills} selectedSkills={controller.selectedSkills}
onSkillsChange={controller.handleSkillsChange} onSkillsChange={controller.handleSkillsChange}
onStop={controller.stopStreaming} personaPicker={{
isStreaming={ personas: controller.personas,
controller.chatState === "streaming" || selectedPersonaId: controller.selectedPersonaId,
controller.chatState === "thinking" onPersonaChange: controller.handlePersonaChange,
} }}
personas={controller.personas} agentModelPicker={{
selectedPersonaId={controller.selectedPersonaId} providers: controller.pickerAgents,
onPersonaChange={controller.handlePersonaChange} providersLoading: controller.providersLoading,
providers={controller.pickerAgents} selectedProvider: controller.selectedProvider,
providersLoading={controller.providersLoading} onProviderChange: controller.handleProviderChange,
selectedProvider={controller.selectedProvider} currentModelId: controller.currentModelId,
onProviderChange={controller.handleProviderChange} currentModelProviderId: controller.currentModelProviderId,
currentModelId={controller.currentModelId} currentModel: controller.currentModelName ?? undefined,
currentModel={controller.currentModelName ?? undefined} availableModels: controller.availableModels,
availableModels={controller.availableModels} modelsLoading: controller.modelsLoading,
modelsLoading={controller.modelsLoading} modelStatusMessage: controller.modelStatusMessage,
modelStatusMessage={controller.modelStatusMessage} onModelChange: controller.handleModelChange,
onModelChange={controller.handleModelChange} onPickerOpen: controller.handlePickerOpen,
onPickerOpen={controller.handlePickerOpen} }}
selectedProjectId={controller.selectedProjectId} projectPicker={{
availableProjects={controller.availableProjects} selectedProjectId: controller.selectedProjectId,
onProjectChange={controller.handleProjectChange} availableProjects: controller.availableProjects,
onCreateProject={(options) => onProjectChange: controller.handleProjectChange,
onCreateProject?.({ onCreateProject: (options) =>
onCreated: (projectId) => { onCreateProject?.({
controller.handleProjectChange(projectId); onCreated: (projectId) => {
options?.onCreated?.(projectId); controller.handleProjectChange(projectId);
}, options?.onCreated?.(projectId);
}) },
} }),
contextTokens={controller.tokenState.accumulatedTotal} }}
contextLimit={controller.tokenState.contextLimit} contextUsage={{
isContextUsageReady={controller.isContextUsageReady} contextTokens: controller.tokenState.accumulatedTotal,
contextLimit: controller.tokenState.contextLimit,
isContextUsageReady: controller.isContextUsageReady,
}}
/> />
); );
} }
@@ -13,6 +13,9 @@
"openPanel": "Open context panel", "openPanel": "Open context panel",
"ringAria": "Context: {{percent}}% used" "ringAria": "Context: {{percent}}% used"
}, },
"errors": {
"sessionPreparationSuperseded": "Session configuration changed while preparing. Try sending again."
},
"contextPanel": { "contextPanel": {
"actions": { "actions": {
"chooseCreateAction": "Choose create action", "chooseCreateAction": "Choose create action",
@@ -13,6 +13,9 @@
"openPanel": "Abrir panel de contexto", "openPanel": "Abrir panel de contexto",
"ringAria": "Contexto: {{percent}}% usado" "ringAria": "Contexto: {{percent}}% usado"
}, },
"errors": {
"sessionPreparationSuperseded": "La configuración de la sesión cambió durante la preparación. Intenta enviar de nuevo."
},
"contextPanel": { "contextPanel": {
"actions": { "actions": {
"chooseCreateAction": "Elegir acción de creación", "chooseCreateAction": "Elegir acción de creación",