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:
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user