feat: extend goose2 context window ux with auto-compaction (#8721)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho 2026-04-21 18:10:22 -07:00 committed by GitHub
parent 7e2fb3ee5c
commit 469c74d8bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2226 additions and 231 deletions

View file

@ -36,14 +36,24 @@ const EXCEPTIONS = {
"Search-as-you-type filtering and draft-aware sidebar highlight logic.",
},
"src/app/AppShell.tsx": {
limit: 730,
limit: 780,
justification:
"Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, and restored project-draft reuse. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).",
"Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, restored project-draft reuse, and app-level compaction settings deep links. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).",
},
"src/features/chat/hooks/useChatSessionController.ts": {
limit: 690,
limit: 840,
justification:
"Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, and chat input orchestration pending a later decomposition pass.",
"Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, context-usage readiness resets, queued-target compaction gating, and auto-compaction-aware send orchestration pending a later decomposition pass.",
},
"src/features/chat/hooks/__tests__/useChatSessionController.test.ts": {
limit: 520,
justification:
"Controller regression coverage now spans model/provider rollback, stale usage resets, compact-before-send, and queued-persona auto-compaction support checks in one hook suite.",
},
"src/features/chat/stores/chatStore.ts": {
limit: 520,
justification:
"Chat runtime state, queued-message persistence, replay loading flags, and usage snapshot tracking still live together in one Zustand store.",
},
"src/features/chat/ui/AgentModelPicker.tsx": {
limit: 570,
@ -71,9 +81,9 @@ const EXCEPTIONS = {
"Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.",
},
"src/features/chat/ui/__tests__/ChatInput.test.tsx": {
limit: 520,
limit: 570,
justification:
"Composer regression coverage spans personas, queueing, attachments, and voice-input edge cases in one interaction-heavy suite.",
"Composer regression coverage spans personas, queueing, attachments, voice-input edge cases, and the compaction popover/settings ingress in one interaction-heavy suite.",
},
"src-tauri/src/commands/projects.rs": {
limit: 520,

View file

@ -6,6 +6,7 @@ import { archiveProject } from "@/features/projects/api/projects";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
import type { SectionId } from "@/features/settings/ui/SettingsModal";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import { TopBar } from "./ui/TopBar";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
@ -30,6 +31,7 @@ import {
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { perfLog } from "@/shared/lib/perfLog";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer";
export type AppView =
| "home"
@ -44,6 +46,18 @@ const SIDEBAR_MIN_WIDTH = 180;
const SIDEBAR_MAX_WIDTH = 380;
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
const SIDEBAR_COLLAPSED_WIDTH = 48;
const SETTINGS_SECTIONS = new Set<SectionId>([
"appearance",
"providers",
"compaction",
"extensions",
"voice",
"general",
"projects",
"chats",
"doctor",
"about",
]);
export function AppShell({ children }: { children?: React.ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
@ -103,14 +117,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const tFlush = performance.now();
useChatStore.getState().setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
const replayMessages = buffer
? sanitizeReplayMessages(buffer)
: undefined;
const replayStats = getReplayPerf(sessionId);
clearReplayPerf(sessionId);
if (buffer && buffer.length > 0) {
useChatStore.getState().setMessages(sessionId, buffer);
if (replayMessages) {
useChatStore.getState().setMessages(sessionId, replayMessages);
}
const t2 = performance.now();
perfLog(
`[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`,
`[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${replayMessages?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`,
);
} catch (err) {
console.error("Failed to load session messages:", err);
@ -360,6 +377,30 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
setSettingsOpen(true);
}, []);
useEffect(() => {
const handleOpenSettingsEvent = (event: Event) => {
const section = (event as CustomEvent<{ section?: string }>).detail
?.section;
if (section && SETTINGS_SECTIONS.has(section as SectionId)) {
openSettings(section as SectionId);
return;
}
openSettings();
};
window.addEventListener(
OPEN_SETTINGS_EVENT,
handleOpenSettingsEvent as EventListener,
);
return () => {
window.removeEventListener(
OPEN_SETTINGS_EVENT,
handleOpenSettingsEvent as EventListener,
);
};
}, [openSettings]);
const handleArchiveChat = useCallback(
async (sessionId: string) => {
const { activeSessionId: currentActiveSessionId } =

View file

@ -0,0 +1,121 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTO_COMPACT_PREFERENCES_EVENT,
AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
DEFAULT_AUTO_COMPACT_THRESHOLD,
} from "../../lib/autoCompact";
const mockGetClient = vi.fn();
vi.mock("@/shared/api/acpConnection", () => ({
getClient: () => mockGetClient(),
}));
import { useAutoCompactPreferences } from "../useAutoCompactPreferences";
describe("useAutoCompactPreferences", () => {
beforeEach(() => {
mockGetClient.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("hydrates from the stored threshold value", async () => {
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi.fn().mockResolvedValue({ value: 0.65 }),
GooseConfigUpsert: vi.fn().mockResolvedValue({}),
},
});
const { result } = renderHook(() => useAutoCompactPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
expect(result.current.autoCompactThreshold).toBe(0.65);
});
it("persists threshold updates and broadcasts them", async () => {
const upsert = vi.fn().mockResolvedValue({});
const read = vi
.fn()
.mockResolvedValueOnce({ value: null })
.mockResolvedValue({ value: 0.9 });
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: read,
GooseConfigUpsert: upsert,
},
});
const eventListener = vi.fn();
window.addEventListener(AUTO_COMPACT_PREFERENCES_EVENT, eventListener);
const { result } = renderHook(() => useAutoCompactPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
await act(async () => {
await result.current.setAutoCompactThreshold(0.9);
});
expect(upsert).toHaveBeenCalledWith({
key: AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
value: 0.9,
});
expect(eventListener).toHaveBeenCalledTimes(1);
expect(result.current.autoCompactThreshold).toBe(0.9);
window.removeEventListener(AUTO_COMPACT_PREFERENCES_EVENT, eventListener);
});
it("marks the preferences hydrated even when the initial read fails", async () => {
mockGetClient.mockRejectedValue(new Error("ACP not ready"));
const { result } = renderHook(() => useAutoCompactPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
expect(result.current.autoCompactThreshold).toBe(
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
});
it("retries hydration after a transient read failure", async () => {
vi.useFakeTimers();
const read = vi
.fn()
.mockRejectedValueOnce(new Error("ACP not ready"))
.mockResolvedValueOnce({ value: 0.65 });
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: read,
GooseConfigUpsert: vi.fn().mockResolvedValue({}),
},
});
const { result } = renderHook(() => useAutoCompactPreferences());
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.isHydrated).toBe(true);
expect(result.current.autoCompactThreshold).toBe(
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(result.current.autoCompactThreshold).toBe(0.65);
expect(read).toHaveBeenCalledTimes(2);
});
});

View file

@ -102,10 +102,27 @@ describe("useChat compaction", () => {
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(messages).toEqual([
expect(messages).toHaveLength(3);
expect(messages[0]).toEqual(
createTextMessage("user-1", "user", "Before compact"),
);
expect(messages[1]).toEqual(
createTextMessage("assistant-1", "assistant", "After compact"),
]);
);
expect(messages[2]).toMatchObject({
role: "system",
content: [
{
type: "systemNotification",
notificationType: "compaction",
text: "Conversation compacted. Older context was summarized.",
},
],
metadata: {
userVisible: true,
agentVisible: false,
},
});
expect(runtime.chatState).toBe("idle");
expect(runtime.error).toBeNull();
expect(useChatStore.getState().loadingSessionIds.has("session-1")).toBe(
@ -113,6 +130,43 @@ describe("useChat compaction", () => {
);
});
it("prepares and compacts the override persona session", async () => {
let preparedPersonaId: string | undefined;
const ensurePrepared = vi.fn(async (personaId?: string) => {
preparedPersonaId = personaId;
});
mockGetGooseSessionId.mockImplementation(
(_sessionId: string, personaId?: string) =>
personaId === "persona-a" && preparedPersonaId === "persona-a"
? "goose-session-a"
: null,
);
const { result } = renderHook(() =>
useChat(
"session-1",
undefined,
undefined,
{ id: "persona-b", name: "Persona B" },
{ ensurePrepared },
),
);
await act(async () => {
await result.current.compactConversation({ id: "persona-a" });
});
expect(ensurePrepared).toHaveBeenCalledWith("persona-a");
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact", {
personaId: "persona-a",
});
expect(mockAcpLoadSession).toHaveBeenCalledWith(
"session-1",
"goose-session-a",
undefined,
);
});
it("blocks new sends while compaction is in flight", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
const compactDeferred = createDeferredPromise();
@ -123,7 +177,7 @@ describe("useChat compaction", () => {
const { result } = renderHook(() => useChat("session-1"));
let compactPromise!: Promise<void>;
let compactPromise!: Promise<unknown>;
await act(async () => {
compactPromise = result.current.compactConversation();
await Promise.resolve();
@ -170,8 +224,8 @@ describe("useChat compaction", () => {
const { result } = renderHook(() => useChat("session-1"));
let firstCompact!: Promise<void>;
let secondCompact!: Promise<void>;
let firstCompact!: Promise<unknown>;
let secondCompact!: Promise<unknown>;
await act(async () => {
firstCompact = result.current.compactConversation();
secondCompact = result.current.compactConversation();

View file

@ -0,0 +1,103 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import { clearReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args),
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
describe("useChat persona preparation", () => {
beforeEach(() => {
mockAcpSendMessage.mockReset();
mockAcpCancelSession.mockReset();
mockAcpLoadSession.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: null,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkspaceBySession: {},
});
useAgentStore.setState({
personas: [
{
id: "persona-a",
displayName: "Persona A",
systemPrompt: "",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
{
id: "persona-b",
displayName: "Persona B",
systemPrompt: "",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
],
personasLoading: false,
agents: [],
agentsLoading: false,
activeAgentId: null,
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpCancelSession.mockResolvedValue(true);
mockAcpLoadSession.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("prepares the override persona before prompting", async () => {
const ensurePrepared = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useChat(
"session-1",
undefined,
undefined,
{ id: "persona-a", name: "Persona A" },
{ ensurePrepared },
),
);
await act(async () => {
await result.current.sendMessage("Hello", { id: "persona-b" });
});
expect(ensurePrepared).toHaveBeenCalledWith("persona-b");
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
personaId: "persona-b",
personaName: "Persona B",
images: undefined,
});
});
});

View file

@ -0,0 +1,399 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
const mockSendMessage = vi.fn();
const mockCompactConversation = vi.fn();
const mockSetSelectedProvider = vi.fn();
const mockResolveSessionCwd = vi.fn();
const mockHandleProviderChange = vi.fn();
const mockHandleModelChange = vi.fn();
let mockSelectedAgentId = "goose";
const INITIAL_TOKEN_STATE = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInput: 0,
accumulatedOutput: 0,
accumulatedTotal: 0,
contextLimit: 0,
};
let mockTokenState = { ...INITIAL_TOKEN_STATE };
let capturedQueuedSend:
| ((
text: string,
overridePersona?: { id: string; name?: string },
attachments?: unknown[],
) => boolean | Promise<boolean>)
| null = null;
vi.mock("../useChat", () => ({
useChat: () => ({
messages: [],
chatState: "idle",
tokenState: mockTokenState,
sendMessage: (...args: unknown[]) => mockSendMessage(...args),
compactConversation: (...args: unknown[]) =>
mockCompactConversation(...args),
stopStreaming: vi.fn(),
streamingMessageId: null,
}),
}));
vi.mock("../useMessageQueue", () => ({
useMessageQueue: (...args: unknown[]) => {
capturedQueuedSend = args[2] as typeof capturedQueuedSend;
return {
queuedMessage: null,
enqueue: vi.fn(),
dismiss: vi.fn(),
};
},
}));
vi.mock("../useAutoCompactPreferences", () => ({
useAutoCompactPreferences: () => ({
autoCompactThreshold: 0.8,
isHydrated: true,
setAutoCompactThreshold: vi.fn(),
}),
}));
vi.mock("../useResolvedAgentModelPicker", () => ({
useResolvedAgentModelPicker: () => ({
selectedAgentId: mockSelectedAgentId,
pickerAgents: [{ id: "goose", label: "Goose" }],
availableModels: [],
modelsLoading: false,
modelStatusMessage: null,
handleProviderChange: (providerId: string) =>
mockHandleProviderChange(providerId),
handleModelChange: (modelId: string) => mockHandleModelChange(modelId),
effectiveModelSelection: {
id: "gpt-4o",
name: "GPT-4o",
providerId: "openai",
source: "explicit" as const,
},
}),
}));
vi.mock("@/features/agents/hooks/useProviderSelection", () => ({
useProviderSelection: () => ({
providers: [
{ id: "goose", label: "Goose" },
{ id: "openai", label: "OpenAI" },
{ id: "anthropic", label: "Anthropic" },
],
providersLoading: false,
selectedProvider: useAgentStore.getState().selectedProvider ?? "openai",
setSelectedProvider: (...args: unknown[]) =>
mockSetSelectedProvider(...args),
}),
}));
vi.mock("@/features/projects/lib/sessionCwdSelection", () => ({
resolveSessionCwd: (...args: unknown[]) => mockResolveSessionCwd(...args),
}));
import { useChatSessionController } from "../useChatSessionController";
describe("useChatSessionController compaction behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCompactConversation.mockResolvedValue("completed");
mockResolveSessionCwd.mockResolvedValue("/tmp/project");
mockTokenState = { ...INITIAL_TOKEN_STATE };
capturedQueuedSend = null;
mockSelectedAgentId = "goose";
useAgentStore.setState({
personas: [],
personasLoading: false,
agents: [],
agentsLoading: false,
providers: [],
providersLoading: false,
selectedProvider: "openai",
activeAgentId: null,
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
});
useProjectStore.setState({
projects: [],
loading: false,
activeProjectId: null,
});
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
draftsBySession: {},
queuedMessageBySession: {},
scrollTargetMessageBySession: {},
activeSessionId: null,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [
{
id: "session-1",
title: "Chat",
providerId: "openai",
modelId: "gpt-4o",
modelName: "GPT-4o",
createdAt: "2026-04-20T00:00:00.000Z",
updatedAt: "2026-04-20T00:00:00.000Z",
messageCount: 0,
},
],
activeSessionId: null,
isLoading: false,
hasHydratedSessions: true,
contextPanelOpenBySession: {},
activeWorkspaceBySession: {},
});
});
it("hides context usage until a fresh usage snapshot exists after switching models", () => {
const store = useChatStore.getState();
store.replaceTokenState(
"session-1",
{
...INITIAL_TOKEN_STATE,
contextLimit: 400_000,
},
false,
);
const { result } = renderHook(() =>
useChatSessionController({ sessionId: "session-1" }),
);
act(() => {
result.current.handleModelChange("claude-sonnet-4");
});
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(runtime.hasUsageSnapshot).toBe(false);
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
});
it("hides context usage after switching models even when a snapshot existed", () => {
const store = useChatStore.getState();
store.replaceTokenState(
"session-1",
{
...INITIAL_TOKEN_STATE,
accumulatedTotal: 12_000,
contextLimit: 400_000,
},
true,
);
const { result } = renderHook(() =>
useChatSessionController({ sessionId: "session-1" }),
);
act(() => {
result.current.handleModelChange("claude-sonnet-4");
});
const runtime = useChatStore.getState().getSessionRuntime("session-1");
expect(runtime.hasUsageSnapshot).toBe(false);
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
});
it("hides pending home context usage after switching models", () => {
const store = useChatStore.getState();
store.replaceTokenState(
"__home_pending__",
{
...INITIAL_TOKEN_STATE,
accumulatedTotal: 12_000,
contextLimit: 400_000,
},
true,
);
const { result } = renderHook(() =>
useChatSessionController({ sessionId: null }),
);
act(() => {
result.current.handleModelChange("claude-sonnet-4");
});
const runtime = useChatStore
.getState()
.getSessionRuntime("__home_pending__");
expect(runtime.hasUsageSnapshot).toBe(false);
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
});
it("auto-compacts goose sessions before sending when the threshold is exceeded", async () => {
mockTokenState = {
...INITIAL_TOKEN_STATE,
accumulatedTotal: 8_500,
contextLimit: 10_000,
};
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useChatSessionStore.getState().updateSession("session-1", {
providerId: "goose",
});
const { result } = renderHook(() =>
useChatSessionController({ sessionId: "session-1" }),
);
await act(async () => {
await result.current.handleSend("hello");
});
expect(mockCompactConversation).toHaveBeenCalledOnce();
expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined);
expect(mockCompactConversation.mock.invocationCallOrder[0]).toBeLessThan(
mockSendMessage.mock.invocationCallOrder[0],
);
});
it("keeps compaction enabled for goose agent sessions backed by model providers", async () => {
mockTokenState = {
...INITIAL_TOKEN_STATE,
accumulatedTotal: 8_500,
contextLimit: 10_000,
};
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
const { result } = renderHook(() =>
useChatSessionController({ sessionId: "session-1" }),
);
expect(result.current.selectedProvider).toBe("goose");
expect(result.current.supportsAutoCompactContext).toBe(true);
expect(result.current.supportsCompactionControls).toBe(true);
await act(async () => {
await result.current.handleSend("hello");
});
expect(mockCompactConversation).toHaveBeenCalledOnce();
expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined);
});
it("compacts the queued persona session before sending", async () => {
mockTokenState = {
...INITIAL_TOKEN_STATE,
accumulatedTotal: 8_500,
contextLimit: 10_000,
};
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useChatSessionStore.getState().updateSession("session-1", {
providerId: "goose",
personaId: "persona-b",
});
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
expect(capturedQueuedSend).not.toBeNull();
await act(async () => {
await capturedQueuedSend?.("hello", { id: "persona-a" });
});
expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" });
expect(mockSendMessage).toHaveBeenCalledWith(
"hello",
{ id: "persona-a" },
undefined,
);
});
it("auto-compacts queued messages for goose personas even after switching away", async () => {
mockSelectedAgentId = "claude-acp";
mockTokenState = {
...INITIAL_TOKEN_STATE,
accumulatedTotal: 8_500,
contextLimit: 10_000,
};
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useAgentStore.setState({
personas: [
{
id: "persona-a",
displayName: "Persona A",
systemPrompt: "",
provider: "openai",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
],
});
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
await act(async () => {
await capturedQueuedSend?.("hello", { id: "persona-a" });
});
expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" });
expect(mockSendMessage).toHaveBeenCalledWith(
"hello",
{ id: "persona-a" },
undefined,
);
});
it("skips auto-compaction for queued messages targeting unsupported personas", async () => {
mockSelectedAgentId = "goose";
mockTokenState = {
...INITIAL_TOKEN_STATE,
accumulatedTotal: 8_500,
contextLimit: 10_000,
};
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useAgentStore.setState({
personas: [
{
id: "persona-a",
displayName: "Persona A",
systemPrompt: "",
provider: "claude-acp",
isBuiltin: false,
createdAt: "",
updatedAt: "",
},
],
});
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
await act(async () => {
await capturedQueuedSend?.("hello", { id: "persona-a" });
});
expect(mockCompactConversation).not.toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalledWith(
"hello",
{ id: "persona-a" },
undefined,
);
});
});

View file

@ -55,6 +55,7 @@ vi.mock("../useMessageQueue", () => ({
useMessageQueue: () => ({
queuedMessage: null,
enqueue: vi.fn(),
dismiss: vi.fn(),
}),
}));
@ -213,7 +214,6 @@ describe("useChatSessionController", () => {
modelName: "Claude Sonnet 4",
});
});
it("restores the previous stored model preference when setting a model fails", async () => {
window.localStorage.setItem(
"goose:preferredModelsByAgent",

View file

@ -147,4 +147,50 @@ describe("useMessageQueue", () => {
undefined,
);
});
it("retries a queued message on the next idle transition after one failure", () => {
const sendMessage = vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
useChatStore.getState().enqueueMessage("s1", { text: "queued" });
const { rerender } = renderHook(
({ chatState }: { chatState: ChatState }) =>
useMessageQueue("s1", chatState, sendMessage),
{ initialProps: { chatState: "idle" as ChatState } },
);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(useChatStore.getState().queuedMessageBySession.s1).toEqual({
text: "queued",
});
rerender({ chatState: "streaming" as const });
rerender({ chatState: "idle" as const });
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(useChatStore.getState().queuedMessageBySession.s1).toBeUndefined();
});
it("stops auto-retrying the same queued message after repeated failures", () => {
const sendMessage = vi.fn().mockReturnValue(false);
useChatStore.getState().enqueueMessage("s1", { text: "queued" });
const { rerender } = renderHook(
({ chatState }: { chatState: ChatState }) =>
useMessageQueue("s1", chatState, sendMessage),
{ initialProps: { chatState: "idle" as ChatState } },
);
rerender({ chatState: "streaming" as const });
rerender({ chatState: "idle" as const });
rerender({ chatState: "streaming" as const });
rerender({ chatState: "idle" as const });
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(useChatStore.getState().queuedMessageBySession.s1).toEqual({
text: "queued",
});
});
});

View file

@ -155,6 +155,7 @@ export function useAgentModelPickerState({
provider: selectedModel?.provider,
providerId: selectedModel?.providerId,
providerName: selectedModel?.providerName,
contextLimit: selectedModel?.contextLimit,
recommended: selectedModel?.recommended,
});
},

View file

@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from "react";
import { getClient } from "@/shared/api/acpConnection";
import {
AUTO_COMPACT_PREFERENCES_EVENT,
AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
DEFAULT_AUTO_COMPACT_THRESHOLD,
normalizeAutoCompactThreshold,
} from "../lib/autoCompact";
const AUTO_COMPACT_RETRY_DELAY_MS = 1000;
type ConfigReadResult =
| {
ok: true;
value: unknown;
}
| {
ok: false;
};
async function readConfigValue(key: string): Promise<ConfigReadResult> {
try {
const client = await getClient();
const response = await client.goose.GooseConfigRead({ key });
return {
ok: true,
value: response.value ?? null,
};
} catch {
return { ok: false };
}
}
async function writeConfigValue(key: string, value: number): Promise<void> {
const client = await getClient();
await client.goose.GooseConfigUpsert({ key, value });
}
export function useAutoCompactPreferences() {
const [autoCompactThreshold, setAutoCompactThresholdState] = useState(
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
const [isHydrated, setIsHydrated] = useState(false);
const [syncVersion, setSyncVersion] = useState(0);
const requestSyncFromConfig = useCallback(() => {
setSyncVersion((current) => current + 1);
}, []);
useEffect(() => {
const handler = () => {
requestSyncFromConfig();
};
window.addEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
return () => {
window.removeEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
};
}, [requestSyncFromConfig]);
const syncFromConfig = useCallback(async (_syncVersion: number) => {
void _syncVersion;
const result = await readConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY);
return result;
}, []);
useEffect(() => {
let cancelled = false;
let retryTimer: number | null = null;
const applyConfig = async () => {
const result = await syncFromConfig(syncVersion);
if (cancelled) {
return;
}
if (result.ok) {
setAutoCompactThresholdState(
normalizeAutoCompactThreshold(result.value),
);
} else {
retryTimer = window.setTimeout(
requestSyncFromConfig,
AUTO_COMPACT_RETRY_DELAY_MS,
);
}
setIsHydrated(true);
};
void applyConfig();
return () => {
cancelled = true;
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
};
}, [requestSyncFromConfig, syncFromConfig, syncVersion]);
const dispatchPreferencesEvent = useCallback(() => {
window.dispatchEvent(new Event(AUTO_COMPACT_PREFERENCES_EVENT));
}, []);
const setAutoCompactThreshold = useCallback(
async (value: number) => {
const normalized = normalizeAutoCompactThreshold(value);
await writeConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY, normalized);
setAutoCompactThresholdState(normalized);
setIsHydrated(true);
dispatchPreferencesEvent();
},
[dispatchPreferencesEvent],
);
return {
autoCompactThreshold,
isHydrated,
setAutoCompactThreshold,
};
}

View file

@ -4,10 +4,8 @@ import { useChatSessionStore } from "../stores/chatSessionStore";
import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer";
import {
type ChatAttachmentDraft,
type Message,
createSystemNotificationMessage,
createUserMessage,
getTextContent,
} from "@/shared/types/messages";
import type { ChatState, TokenState } from "@/shared/types/chat";
import {
@ -28,25 +26,18 @@ import {
buildAttachmentPromptPreamble,
buildMessageAttachments,
} from "../lib/attachments";
import { sanitizeReplayMessages } from "../lib/replaySanitizer";
import { i18n } from "@/shared/i18n";
// TODO: Remove this fallback once goose2 has first-class /-commands.
const MANUAL_COMPACT_TRIGGER = "/compact";
type CompactConversationResult = "completed" | "failed" | "skipped";
function isManualCompactCommandMessage(message: Message): boolean {
if (message.role !== "user") {
return false;
}
const normalizedText = getTextContent(message).replace(/\s+/g, "");
if (!normalizedText) {
return false;
}
return normalizedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0;
}
function removeManualCompactCommandMessages(messages: Message[]): Message[] {
return messages.filter((message) => !isManualCompactCommandMessage(message));
function createCompactionConfirmationMessage() {
return createSystemNotificationMessage(
i18n.t("chat:notifications.compactionComplete"),
"compaction",
);
}
function getErrorMessage(error: unknown): string {
@ -109,7 +100,7 @@ export function useChat(
personaInfo?: { id: string; name: string },
options?: {
onMessageAccepted?: (sessionId: string) => void;
ensurePrepared?: () => Promise<void>;
ensurePrepared?: (personaId?: string) => Promise<void>;
},
) {
const store = useChatStore();
@ -244,7 +235,7 @@ export function useChat(
streamingPersonaIdRef.current = effectivePersonaInfo?.id ?? null;
try {
await options?.ensurePrepared?.();
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
store.setChatState(sessionId, "streaming");
// When images are present with no text, pass a single space so the ACP
@ -382,77 +373,102 @@ export function useChat(
[sessionId],
);
const compactConversation = useCallback(async () => {
const currentChatState = useChatStore
.getState()
.getSessionRuntime(sessionId).chatState;
if (currentChatState !== "idle") {
return;
}
const effectivePersonaInfo = resolvePersonaInfo();
const gooseSessionId = getGooseSessionId(
sessionId,
effectivePersonaInfo?.id,
);
if (!gooseSessionId) {
const errorMessage =
"Session not prepared. Send a message before compacting.";
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return;
}
store.setActiveSession(sessionId);
store.setChatState(sessionId, "compacting");
store.setStreamingMessageId(sessionId, null);
store.setError(sessionId, null);
store.setSessionLoading(sessionId, true);
clearReplayBuffer(sessionId);
try {
const sendOptions = effectivePersonaInfo?.id
? { personaId: effectivePersonaInfo.id }
: undefined;
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
// Command responses are streamed via prompt notifications, but the ACP
// layer does not currently forward history replacement events. Drop those
// transient chunks and refresh the session from replay instead.
clearReplayBuffer(sessionId);
const workingDir = getWorkingDir();
await acpLoadSession(sessionId, gooseSessionId, workingDir);
store.setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer) {
store.setMessages(
sessionId,
removeManualCompactCommandMessages(buffer),
);
const compactConversation = useCallback(
async (overridePersona?: { id: string; name?: string }) => {
const currentChatState = useChatStore
.getState()
.getSessionRuntime(sessionId).chatState;
if (currentChatState !== "idle") {
return "skipped" as CompactConversationResult;
}
} catch (err) {
clearReplayBuffer(sessionId);
store.setSessionLoading(sessionId, false);
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
const effectivePersonaInfo = resolvePersonaInfo(
overridePersona?.id,
overridePersona?.name,
);
store.setError(sessionId, errorMessage);
} finally {
store.setChatState(sessionId, "idle");
let gooseSessionId = getGooseSessionId(
sessionId,
effectivePersonaInfo?.id,
);
if (!gooseSessionId) {
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
} catch (err) {
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
}
gooseSessionId = getGooseSessionId(sessionId, effectivePersonaInfo?.id);
}
if (!gooseSessionId) {
const errorMessage =
"Session not prepared. Send a message before compacting.";
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
}
store.setActiveSession(sessionId);
store.setChatState(sessionId, "compacting");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
store.setSessionLoading(sessionId, false);
}
}, [getWorkingDir, resolvePersonaInfo, sessionId, store]);
store.setError(sessionId, null);
store.setSessionLoading(sessionId, true);
clearReplayBuffer(sessionId);
try {
const sendOptions = effectivePersonaInfo?.id
? { personaId: effectivePersonaInfo.id }
: undefined;
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
// Command responses are streamed via prompt notifications, but the ACP
// layer does not currently forward history replacement events. Drop those
// transient chunks and refresh the session from replay instead.
clearReplayBuffer(sessionId);
const workingDir = getWorkingDir();
await acpLoadSession(sessionId, gooseSessionId, workingDir);
store.setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer) {
store.setMessages(sessionId, [
...sanitizeReplayMessages(buffer),
createCompactionConfirmationMessage(),
]);
} else {
store.addMessage(sessionId, createCompactionConfirmationMessage());
}
return "completed" as CompactConversationResult;
} catch (err) {
clearReplayBuffer(sessionId);
store.setSessionLoading(sessionId, false);
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
} finally {
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
store.setSessionLoading(sessionId, false);
}
},
[getWorkingDir, options, resolvePersonaInfo, sessionId, store],
);
const stopStreaming = stopGeneration;

View file

@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { INITIAL_TOKEN_STATE } from "@/shared/types/chat";
import { useChat } from "./useChat";
import { useAutoCompactPreferences } from "./useAutoCompactPreferences";
import { useMessageQueue } from "./useMessageQueue";
import { useChatStore } from "../stores/chatStore";
import { useChatSessionStore } from "../stores/chatSessionStore";
@ -15,6 +17,11 @@ import {
resolveProjectDefaultArtifactRoot,
} from "@/features/projects/lib/chatProjectContext";
import { setStoredModelPreference } from "../lib/modelPreferences";
import {
shouldAutoCompactContext,
supportsContextAutoCompaction,
supportsContextCompactionControls,
} from "../lib/autoCompact";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import {
@ -77,6 +84,11 @@ export function useChatSessionController({
: undefined,
);
const project = storedProject ?? null;
const { autoCompactThreshold, isHydrated: isAutoCompactThresholdHydrated } =
useAutoCompactPreferences();
const hasContextUsageSnapshot = useChatStore(
(s) => s.sessionStateById[stateSessionId]?.hasUsageSnapshot ?? false,
);
const selectedProvider =
pendingProviderId ??
session?.providerId ??
@ -253,6 +265,29 @@ export function useChatSessionController({
sessionId,
]);
const handleProviderChangeWithContextReset = useCallback(
(providerId: string) => {
if (providerId === selectedProvider) {
return;
}
useChatStore.getState().resetTokenState(stateSessionId);
handleProviderChange(providerId);
},
[handleProviderChange, selectedProvider, stateSessionId],
);
const handleModelChangeWithContextReset = useCallback(
(modelId: string) => {
if (modelId === effectiveModelSelection?.id) {
return;
}
useChatStore.getState().resetTokenState(stateSessionId);
handleModelChange(modelId);
},
[effectiveModelSelection?.id, handleModelChange, stateSessionId],
);
const handleProjectChange = useCallback(
(projectId: string | null) => {
if (!sessionId) {
@ -364,6 +399,7 @@ export function useChatSessionController({
chatState,
tokenState,
sendMessage,
compactConversation,
stopStreaming,
streamingMessageId,
} = useChat(
@ -374,11 +410,92 @@ export function useChatSessionController({
{
onMessageAccepted: sessionId ? onMessageAccepted : undefined,
ensurePrepared: selectedProvider
? () =>
prepareSelectedProvider(selectedProvider, effectiveModelSelection)
? (personaId?: string) =>
prepareCurrentSession(
selectedProvider,
project,
activeWorkspace?.path,
personaId,
)
: undefined,
},
);
const resolvedTokenState = tokenState ?? INITIAL_TOKEN_STATE;
const supportsAutoCompactContext =
supportsContextAutoCompaction(selectedAgentId);
const supportsCompactionControls =
supportsContextCompactionControls(selectedAgentId);
const isCompactingContext = chatState === "compacting";
const resolveAutoCompactAgentId = useCallback(
(overridePersona?: { id: string; name?: string }) => {
if (!overridePersona?.id) {
return selectedAgentId;
}
const targetPersona = personas.find(
(persona) => persona.id === overridePersona.id,
);
if (!targetPersona?.provider) {
return selectedAgentId;
}
return (
resolveAgentProviderCatalogIdStrict(targetPersona.provider) ?? "goose"
);
},
[personas, selectedAgentId],
);
const canAutoCompactBeforeSend = useCallback(
(overridePersona?: { id: string; name?: string }) => {
const targetAgentId = resolveAutoCompactAgentId(overridePersona);
if (
!sessionId ||
!supportsContextAutoCompaction(targetAgentId) ||
!isAutoCompactThresholdHydrated
) {
return false;
}
const liveRuntime = useChatStore
.getState()
.getSessionRuntime(stateSessionId);
return shouldAutoCompactContext(
liveRuntime.tokenState.accumulatedTotal,
liveRuntime.tokenState.contextLimit,
autoCompactThreshold,
);
},
[
autoCompactThreshold,
isAutoCompactThresholdHydrated,
resolveAutoCompactAgentId,
sessionId,
stateSessionId,
],
);
const sendWithAutoCompact = useCallback(
(
text: string,
overridePersona?: { id: string; name?: string },
attachments?: ChatAttachmentDraft[],
) => {
if (!canAutoCompactBeforeSend(overridePersona)) {
void sendMessage(text, overridePersona, attachments);
return true;
}
return (async () => {
const compactionResult = await compactConversation(overridePersona);
if (compactionResult !== "completed") {
return false;
}
void sendMessage(text, overridePersona, attachments);
return true;
})();
},
[canAutoCompactBeforeSend, compactConversation, sendMessage],
);
const isLoadingHistory = useChatStore((s) =>
sessionId
? s.loadingSessionIds.has(sessionId) &&
@ -388,11 +505,12 @@ export function useChatSessionController({
const deferredSend = useRef<{
text: string;
attachments?: ChatAttachmentDraft[];
resolve?: (accepted: boolean) => void;
} | null>(null);
const queue = useMessageQueue(
stateSessionId,
sessionId ? chatState : "thinking",
sendMessage,
sendWithAutoCompact,
);
const handleSend = useCallback(
@ -401,21 +519,22 @@ export function useChatSessionController({
if (!queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
}
return;
return true;
}
if (personaId && personaId !== selectedPersonaId) {
handlePersonaChange(personaId);
deferredSend.current = { text, attachments };
return;
return new Promise<boolean>((resolve) => {
deferredSend.current = { text, attachments, resolve };
});
}
if (chatState !== "idle" && !queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
return;
return true;
}
sendMessage(text, undefined, attachments);
return sendWithAutoCompact(text, undefined, attachments);
},
[
chatState,
@ -423,17 +542,27 @@ export function useChatSessionController({
queue,
sessionId,
selectedPersonaId,
sendMessage,
sendWithAutoCompact,
],
);
useEffect(() => {
if (deferredSend.current && selectedPersona) {
const { text, attachments } = deferredSend.current;
const { text, attachments, resolve } = deferredSend.current;
deferredSend.current = null;
sendMessage(text, undefined, attachments);
const sendResult = sendWithAutoCompact(text, undefined, attachments);
if (sendResult instanceof Promise) {
void sendResult.then((accepted) => {
if (accepted === false) {
useChatStore.getState().setDraft(stateSessionId, text);
}
resolve?.(accepted !== false);
});
return;
}
resolve?.(true);
}
}, [selectedPersona, sendMessage]);
}, [selectedPersona, sendWithAutoCompact, stateSessionId]);
const handleCreatePersona = useCallback(() => {
if (onCreatePersonaRequested) {
@ -605,9 +734,17 @@ export function useChatSessionController({
allowedArtifactRoots,
messages,
chatState,
tokenState,
tokenState: resolvedTokenState,
stopStreaming,
streamingMessageId,
compactConversation,
canCompactContext:
supportsCompactionControls && messages.length > 0 && chatState === "idle",
isCompactingContext,
supportsAutoCompactContext,
supportsCompactionControls,
isContextUsageReady:
hasContextUsageSnapshot && resolvedTokenState.contextLimit > 0,
isLoadingHistory,
queue,
handleSend,
@ -623,13 +760,13 @@ export function useChatSessionController({
pickerAgents,
providersLoading,
selectedProvider: selectedAgentId,
handleProviderChange,
handleProviderChange: handleProviderChangeWithContextReset,
currentModelId: effectiveModelSelection?.id ?? null,
currentModelName: effectiveModelSelection?.name ?? null,
availableModels,
modelsLoading,
modelStatusMessage,
handleModelChange,
handleModelChange: handleModelChangeWithContextReset,
selectedProjectId: effectiveProjectId,
availableProjects,
handleProjectChange,

View file

@ -1,8 +1,35 @@
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useMemo, useRef } from "react";
import type { ChatState } from "@/shared/types/chat";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { useChatStore } from "../stores/chatStore";
const MAX_CONSECUTIVE_SEND_FAILURES = 2;
function getQueuedMessageKey(
queuedMessage: {
text: string;
personaId?: string;
attachments?: ChatAttachmentDraft[];
} | null,
): string | null {
if (!queuedMessage) {
return null;
}
return JSON.stringify({
text: queuedMessage.text,
personaId: queuedMessage.personaId ?? null,
attachments:
queuedMessage.attachments?.map((attachment) => ({
id: attachment.id,
kind: attachment.kind,
name: attachment.name,
path: "path" in attachment ? (attachment.path ?? null) : null,
})) ?? [],
});
}
/**
* Single-slot message queue that holds one pending message while the agent is
* busy and auto-sends it when the chat transitions back to idle.
@ -18,19 +45,104 @@ export function useMessageQueue(
text: string,
overridePersona?: { id: string; name?: string },
attachments?: ChatAttachmentDraft[],
) => void,
) => boolean | Promise<boolean>,
) {
const queuedMessage = useChatStore(
(s) => s.queuedMessageBySession[sessionId] ?? null,
);
const previousChatStateRef = useRef(chatState);
const idleCycleRef = useRef(0);
const lastAttemptRef = useRef<{
key: string;
idleCycle: number;
} | null>(null);
const failureStateRef = useRef<{
key: string;
count: number;
} | null>(null);
const queuedMessageKey = useMemo(
() => getQueuedMessageKey(queuedMessage),
[queuedMessage],
);
useEffect(() => {
if (chatState === "idle" && queuedMessage) {
const { text, personaId, attachments } = queuedMessage;
useChatStore.getState().dismissQueuedMessage(sessionId);
sendMessage(text, personaId ? { id: personaId } : undefined, attachments);
if (queuedMessageKey !== lastAttemptRef.current?.key) {
lastAttemptRef.current = null;
}
}, [chatState, queuedMessage, sendMessage, sessionId]);
if (queuedMessageKey !== failureStateRef.current?.key) {
failureStateRef.current = null;
}
}, [queuedMessageKey]);
useEffect(() => {
if (chatState === "idle" && previousChatStateRef.current !== "idle") {
idleCycleRef.current += 1;
}
previousChatStateRef.current = chatState;
}, [chatState]);
useEffect(() => {
const hasReachedRetryLimit =
failureStateRef.current?.key === queuedMessageKey &&
failureStateRef.current.count >= MAX_CONSECUTIVE_SEND_FAILURES;
const alreadyAttemptedThisIdleCycle =
lastAttemptRef.current?.key === queuedMessageKey &&
lastAttemptRef.current.idleCycle === idleCycleRef.current;
if (
chatState !== "idle" ||
!queuedMessage ||
!queuedMessageKey ||
hasReachedRetryLimit ||
alreadyAttemptedThisIdleCycle
) {
return;
}
lastAttemptRef.current = {
key: queuedMessageKey,
idleCycle: idleCycleRef.current,
};
const { text, personaId, attachments } = queuedMessage;
const sendResult = sendMessage(
text,
personaId ? { id: personaId } : undefined,
attachments,
);
const finalize = (accepted: boolean | undefined) => {
const latestQueuedMessage =
useChatStore.getState().queuedMessageBySession[sessionId] ?? null;
if (getQueuedMessageKey(latestQueuedMessage) !== queuedMessageKey) {
return;
}
if (accepted === false) {
const previousFailureCount =
failureStateRef.current?.key === queuedMessageKey
? failureStateRef.current.count
: 0;
failureStateRef.current = {
key: queuedMessageKey,
count: previousFailureCount + 1,
};
return;
}
failureStateRef.current = null;
lastAttemptRef.current = null;
useChatStore.getState().dismissQueuedMessage(sessionId);
};
if (isPromiseLike<boolean>(sendResult)) {
void sendResult
.then((accepted) => finalize(accepted))
.catch(() => finalize(false));
} else {
finalize(sendResult);
}
}, [chatState, queuedMessage, queuedMessageKey, sendMessage, sessionId]);
const enqueue = useCallback(
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {

View file

@ -97,37 +97,22 @@ export function useResolvedAgentModelPicker({
}
const inventoryEntry = getProviderInventoryEntry(agentId);
if (!inventoryEntry?.defaultModel) {
if (!inventoryEntry) {
return null;
}
const resolvedInventoryModel =
inventoryEntry.models.find(
(model) =>
model.id === inventoryEntry.defaultModel && !isModelAlias(model.id),
) ??
inventoryEntry.models.find((model) => model.recommended) ??
inventoryEntry.models.find((model) => !isModelAlias(model.id)) ??
inventoryEntry.models.find(
(model) => model.id === inventoryEntry.defaultModel,
) ??
inventoryEntry.models[0];
if (resolvedInventoryModel) {
return {
id: resolvedInventoryModel.id,
name: resolvedInventoryModel.name,
providerId:
inventoryEntry.providerId === agentId
? inventoryEntry.providerId
: fallbackProviderId,
source: "default" as const,
};
if (!resolvedInventoryModel) {
return null;
}
return {
id: inventoryEntry.defaultModel,
name: inventoryEntry.defaultModel,
id: resolvedInventoryModel.id,
name: resolvedInventoryModel.name,
providerId:
inventoryEntry.providerId === agentId
? inventoryEntry.providerId

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getDictationConfig } from "@/shared/api/dictation";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import type { DictationProviderStatus } from "@/shared/types/dictation";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import { useDictationRecorder } from "./useDictationRecorder";
@ -21,7 +22,7 @@ interface UseVoiceDictationOptions {
text: string,
personaId?: string,
attachments?: ChatAttachmentDraft[],
) => void;
) => boolean | Promise<boolean>;
resetTextarea: () => void;
/**
* When true, auto-submit on trigger phrase will NOT call `onSend`.
@ -139,11 +140,35 @@ export function useVoiceDictation({
textRef.current = merged;
return;
}
onSend(
const sendResult = onSend(
merged.trim(),
selectedPersonaId ?? undefined,
attachments.length > 0 ? attachments : undefined,
);
if (isPromiseLike<boolean>(sendResult)) {
void sendResult
.then((accepted) => {
if (accepted === false) {
setText(merged);
textRef.current = merged;
return;
}
setText("");
textRef.current = "";
clearAttachments();
resetTextarea();
})
.catch(() => {
setText(merged);
textRef.current = merged;
});
return;
}
if (sendResult === false) {
setText(merged);
textRef.current = merged;
return;
}
setText("");
textRef.current = "";
clearAttachments();

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { Message } from "@/shared/types/messages";
import { sanitizeReplayMessages } from "../replaySanitizer";
function createTextMessage(
id: string,
role: Message["role"],
text: string,
): Message {
return {
id,
role,
created: 0,
content: [{ type: "text", text }],
metadata: {
userVisible: true,
agentVisible: role !== "system",
},
};
}
describe("sanitizeReplayMessages", () => {
it("removes manual compaction control messages from replayed history", () => {
expect(
sanitizeReplayMessages([
createTextMessage("user-1", "user", "Before compact"),
createTextMessage("compact-1", "user", "/compact"),
createTextMessage("compact-2", "user", "/compact/compact"),
createTextMessage("compact-4", "user", "/summarize"),
createTextMessage("assistant-1", "assistant", "After compact"),
]),
).toEqual([
createTextMessage("user-1", "user", "Before compact"),
createTextMessage("assistant-1", "assistant", "After compact"),
]);
});
it("keeps natural-language requests to compact the conversation", () => {
expect(
sanitizeReplayMessages([
createTextMessage("user-1", "user", "Please compact this conversation"),
]),
).toEqual([
createTextMessage("user-1", "user", "Please compact this conversation"),
]);
});
it("keeps normal user messages that merely mention compact commands", () => {
expect(
sanitizeReplayMessages([
createTextMessage(
"user-1",
"user",
"Can you explain what /compact does?",
),
]),
).toEqual([
createTextMessage(
"user-1",
"user",
"Can you explain what /compact does?",
),
]);
});
});

View file

@ -0,0 +1,96 @@
export const AUTO_COMPACT_THRESHOLD_CONFIG_KEY = "GOOSE_AUTO_COMPACT_THRESHOLD";
export const AUTO_COMPACT_PREFERENCES_EVENT = "goose:auto-compact-preferences";
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 0.8;
export const MIN_AUTO_COMPACT_THRESHOLD_PERCENT = 1;
export const MAX_AUTO_COMPACT_THRESHOLD_PERCENT = 100;
const CONTEXT_COMPACTION_PROVIDER_IDS = new Set(["goose"]);
function coerceAutoCompactThreshold(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
export function normalizeAutoCompactThreshold(value: unknown): number {
const parsed = coerceAutoCompactThreshold(value);
if (parsed === null) {
return DEFAULT_AUTO_COMPACT_THRESHOLD;
}
if (parsed <= 0 || parsed >= 1) {
return 1;
}
return parsed;
}
export function clampAutoCompactThresholdPercent(value: number): number {
if (!Number.isFinite(value)) {
return Math.round(DEFAULT_AUTO_COMPACT_THRESHOLD * 100);
}
return Math.max(
MIN_AUTO_COMPACT_THRESHOLD_PERCENT,
Math.min(MAX_AUTO_COMPACT_THRESHOLD_PERCENT, Math.round(value)),
);
}
export function autoCompactThresholdToPercent(value: unknown): number {
const parsed = coerceAutoCompactThreshold(value);
if (parsed === null) {
return Math.round(DEFAULT_AUTO_COMPACT_THRESHOLD * 100);
}
if (parsed <= 0 || parsed >= 1) {
return MAX_AUTO_COMPACT_THRESHOLD_PERCENT;
}
return clampAutoCompactThresholdPercent(parsed * 100);
}
export function autoCompactPercentToThreshold(value: number): number {
return clampAutoCompactThresholdPercent(value) / 100;
}
export function shouldAutoCompactContext(
usedTokens: number,
contextLimit: number,
threshold: number,
): boolean {
if (usedTokens <= 0 || contextLimit <= 0) {
return false;
}
if (threshold <= 0 || threshold >= 1) {
return false;
}
return usedTokens / contextLimit > threshold;
}
function supportsContextCompactionProvider(
providerId: string | null | undefined,
): boolean {
return providerId != null && CONTEXT_COMPACTION_PROVIDER_IDS.has(providerId);
}
export function supportsContextAutoCompaction(
providerId: string | null | undefined,
): boolean {
return supportsContextCompactionProvider(providerId);
}
export function supportsContextCompactionControls(
providerId: string | null | undefined,
): boolean {
return supportsContextCompactionProvider(providerId);
}

View file

@ -0,0 +1,31 @@
import type { Message } from "@/shared/types/messages";
import { getTextContent } from "@/shared/types/messages";
const MANUAL_COMPACT_TRIGGER = "/compact";
const ALTERNATE_COMPACT_TRIGGERS = new Set(["/summarize"]);
export function isManualCompactReplayArtifact(message: Message): boolean {
if (message.role !== "user") {
return false;
}
const rawText = getTextContent(message).trim();
if (!rawText) {
return false;
}
const normalizedText = rawText.replace(/\s+/g, " ").trim().toLowerCase();
if (ALTERNATE_COMPACT_TRIGGERS.has(normalizedText)) {
return true;
}
const collapsedText = normalizedText.replace(/\s+/g, "");
return (
collapsedText.length > 0 &&
collapsedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0
);
}
export function sanitizeReplayMessages(messages: Message[]): Message[] {
return messages.filter((message) => !isManualCompactReplayArtifact(message));
}

View file

@ -59,9 +59,11 @@ describe("chatStore", () => {
expect(runtime.chatState).toBe("streaming");
expect(runtime.streamingMessageId).toBe("stream-1");
expect(runtime.tokenState.totalTokens).toBe(20);
expect(runtime.hasUsageSnapshot).toBe(true);
expect(getRuntime("s2").chatState).toBe("idle");
expect(getRuntime("s2").tokenState).toEqual(INITIAL_TOKEN_STATE);
expect(getRuntime("s2").hasUsageSnapshot).toBe(false);
});
it("appends streamed text only within the targeted session", () => {

View file

@ -103,6 +103,11 @@ interface ChatStoreActions {
markSessionRead: (sessionId: string) => void;
markSessionUnread: (sessionId: string) => void;
updateTokenState: (sessionId: string, state: Partial<TokenState>) => void;
replaceTokenState: (
sessionId: string,
tokenState: TokenState,
hasUsageSnapshot?: boolean,
) => void;
resetTokenState: (sessionId: string) => void;
enqueueMessage: (sessionId: string, message: QueuedMessage) => void;
dismissQueuedMessage: (sessionId: string) => void;
@ -382,11 +387,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
accumulatedTotal,
contextLimit: partial.contextLimit ?? current.contextLimit,
},
hasUsageSnapshot: true,
},
},
};
}),
replaceTokenState: (sessionId, tokenState, hasUsageSnapshot = true) =>
set((state) => ({
sessionStateById: {
...state.sessionStateById,
[sessionId]: {
...(state.sessionStateById[sessionId] ??
createInitialSessionRuntime()),
tokenState: { ...tokenState },
hasUsageSnapshot,
},
},
})),
resetTokenState: (sessionId) =>
set((state) => ({
sessionStateById: {
@ -395,6 +414,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
...(state.sessionStateById[sessionId] ??
createInitialSessionRuntime()),
tokenState: { ...INITIAL_TOKEN_STATE },
hasUsageSnapshot: false,
},
},
})),

View file

@ -9,6 +9,7 @@ export interface ModelOption {
provider?: string;
providerId?: string;
providerName?: string;
contextLimit?: number | null;
/** Whether this model should appear in the compact recommended picker. */
recommended?: boolean;
}
@ -25,7 +26,7 @@ export interface ChatInputProps {
text: string,
personaId?: string,
attachments?: ChatAttachmentDraft[],
) => void;
) => boolean | Promise<boolean>;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
@ -56,7 +57,9 @@ export interface ChatInputProps {
}) => void;
contextTokens?: number;
contextLimit?: number;
onCompactContext?: () => void | Promise<void>;
isContextUsageReady?: boolean;
onCompactContext?: () => Promise<unknown> | undefined;
canCompactContext?: boolean;
isCompactingContext?: boolean;
supportsCompactionControls?: boolean;
}

View file

@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverAnchor } from "@/shared/ui/popover";
@ -19,8 +20,19 @@ import {
} from "../hooks/useChatInputAttachments";
import { ChatInputAttachments } from "./ChatInputAttachments";
import { useVoiceDictation } from "../hooks/useVoiceDictation";
import type { ChatAttachmentDraft } from "@/shared/types/messages";
import type { ChatInputProps } from "../types";
function attachmentSnapshotsMatch(
current: ChatAttachmentDraft[],
snapshot: ChatAttachmentDraft[],
) {
return (
current.length === snapshot.length &&
current.every((attachment, index) => attachment.id === snapshot[index]?.id)
);
}
export function ChatInput({
onSend,
onStop,
@ -51,17 +63,22 @@ export function ChatInput({
onCreateProject,
contextTokens = 0,
contextLimit = 0,
isContextUsageReady,
onCompactContext,
canCompactContext = false,
isCompactingContext = false,
supportsCompactionControls,
}: ChatInputProps) {
const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue);
const textRef = useRef(initialValue);
useEffect(() => {
setTextRaw(initialValue);
textRef.current = initialValue;
}, [initialValue]);
const setText = useCallback(
(value: string) => {
textRef.current = value;
setTextRaw(value);
onDraftChange?.(value);
},
@ -77,6 +94,8 @@ export function ChatInput({
removeAttachment,
clearAttachments,
} = useChatInputAttachments();
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
const resetTextarea = useCallback(() => {
if (textareaRef.current) {
@ -152,7 +171,7 @@ export function ChatInput({
useEffect(() => textareaRef.current?.focus(), []);
const handleSend = useCallback(() => {
const handleSend = useCallback(async () => {
if (!canSend) {
return;
}
@ -175,11 +194,25 @@ export function ChatInput({
dictation.stopRecording({ flushPending: false });
}
onSend(
text.trim(),
const submittedText = text;
const submittedAttachments = attachments;
const sendResult = onSend(
submittedText.trim(),
selectedPersonaId ?? undefined,
attachments.length > 0 ? attachments : undefined,
submittedAttachments.length > 0 ? submittedAttachments : undefined,
);
const accepted = isPromiseLike<boolean>(sendResult)
? await sendResult
: sendResult;
if (accepted === false) {
return;
}
const draftStillMatchesSubmission =
textRef.current === submittedText &&
attachmentSnapshotsMatch(attachmentsRef.current, submittedAttachments);
if (!draftStillMatchesSubmission) {
return;
}
setText("");
clearAttachments();
if (textareaRef.current) {
@ -219,7 +252,7 @@ export function ChatInput({
}
if (event.key === "Enter" && !event.shiftKey && !event.altKey) {
event.preventDefault();
handleSend();
void handleSend();
}
};
@ -447,9 +480,11 @@ export function ChatInput({
onCreateProject={onCreateProject}
contextTokens={contextTokens}
contextLimit={contextLimit}
isContextUsageReady={isContextUsageReady}
onCompactContext={onCompactContext}
canCompactContext={canCompactContext}
isCompactingContext={isCompactingContext}
supportsCompactionControls={supportsCompactionControls}
canSend={canSend}
isStreaming={isStreaming}
hasQueuedMessage={hasQueuedMessage}

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Mic,
ArrowUp,
@ -6,6 +6,7 @@ import {
Paperclip,
File,
FolderOpen,
Settings2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocaleFormatting } from "@/shared/i18n";
@ -31,6 +32,8 @@ import { AgentModelPicker } from "./AgentModelPicker";
import type { ModelOption } from "../types";
import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
import { getCatalogEntry } from "@/features/providers/providerCatalog";
import { supportsContextCompactionControls } from "../lib/autoCompact";
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
const NO_PROJECT_VALUE = "__no_project__";
const CREATE_PROJECT_VALUE = "__create_project__";
@ -76,10 +79,12 @@ interface ChatInputToolbarProps {
// Context
contextTokens: number;
contextLimit: number;
isContextUsageReady?: boolean;
supportsCompactionControls?: boolean;
// Actions
canCompactContext?: boolean;
isCompactingContext?: boolean;
onCompactContext?: () => void | Promise<void>;
onCompactContext?: () => Promise<unknown> | undefined;
canSend: boolean;
isStreaming: boolean;
hasQueuedMessage: boolean;
@ -118,6 +123,8 @@ export function ChatInputToolbar({
onCreateProject,
contextTokens,
contextLimit,
isContextUsageReady,
supportsCompactionControls,
canCompactContext = false,
isCompactingContext = false,
onCompactContext,
@ -138,6 +145,9 @@ export function ChatInputToolbar({
const { t } = useTranslation("chat");
const { formatNumber } = useLocaleFormatting();
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
const compactionControlsSupported =
supportsCompactionControls ??
supportsContextCompactionControls(selectedProvider);
const agentProviders = useMemo(() => {
const seen = new Set<string>();
@ -171,6 +181,7 @@ export function ChatInputToolbar({
: undefined;
const contextProgress =
contextLimit > 0 ? Math.min(contextTokens / contextLimit, 1) : 0;
const showContextUsage = isContextUsageReady ?? contextLimit > 0;
const contextPercentDigits =
contextProgress > 0 && contextProgress < 0.1 ? 1 : 0;
const usedPercentLabel = formatNumber(contextProgress, {
@ -203,6 +214,17 @@ export function ChatInputToolbar({
void onCompactContext();
};
const handleOpenAutoCompactSettings = () => {
setIsContextPopoverOpen(false);
requestOpenSettings("compaction");
};
useEffect(() => {
if (!showContextUsage && isContextPopoverOpen) {
setIsContextPopoverOpen(false);
}
}, [isContextPopoverOpen, showContextUsage]);
return (
<div className="flex items-center justify-between gap-2">
{/* Left side: pickers */}
@ -286,7 +308,7 @@ export function ChatInputToolbar({
/>
)}
{contextLimit > 0 && (
{showContextUsage && (
<Popover
open={isContextPopoverOpen}
onOpenChange={setIsContextPopoverOpen}
@ -317,7 +339,7 @@ export function ChatInputToolbar({
side="top"
align="end"
sideOffset={8}
className="w-52 rounded-2xl p-1 text-left"
className="w-60 rounded-2xl p-1 text-left"
>
<div className="px-2 py-1.5 text-sm font-semibold text-foreground">
{t("toolbar.contextWindow")}
@ -336,18 +358,33 @@ export function ChatInputToolbar({
</div>
<div className="shrink-0">{usedPercentLabel}</div>
</div>
<Button
type="button"
variant="secondary"
size="xs"
className="w-full justify-center"
onClick={handleCompactContext}
disabled={!canCompactContext || isCompactingContext}
>
{isCompactingContext
? t("toolbar.compacting")
: t("toolbar.compactNow")}
</Button>
{compactionControlsSupported ? (
<div className="flex items-center gap-1 pt-0.5">
<Button
type="button"
variant="secondary"
size="xs"
className="min-w-0 flex-1 justify-center"
onClick={handleCompactContext}
disabled={!canCompactContext || isCompactingContext}
>
{isCompactingContext
? t("toolbar.compacting")
: t("toolbar.compactNow")}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 rounded-full"
onClick={handleOpenAutoCompactSettings}
aria-label={t("toolbar.settings")}
title={t("toolbar.settings")}
>
<Settings2 className="size-4" />
</Button>
</div>
) : null}
</div>
</PopoverContent>
</Popover>

View file

@ -111,7 +111,10 @@ export function ChatView({
<ChatInput
onSend={controller.handleSend}
disabled={controller.projectMetadataPending}
disabled={
controller.projectMetadataPending ||
controller.isCompactingContext
}
queuedMessage={controller.queue.queuedMessage}
onDismissQueue={controller.queue.dismiss}
initialValue={controller.draftValue}
@ -148,6 +151,11 @@ export function ChatView({
}
contextTokens={controller.tokenState.accumulatedTotal}
contextLimit={controller.tokenState.contextLimit}
isContextUsageReady={controller.isContextUsageReady}
onCompactContext={controller.compactConversation}
canCompactContext={controller.canCompactContext}
isCompactingContext={controller.isCompactingContext}
supportsCompactionControls={controller.supportsCompactionControls}
/>
</div>

View file

@ -262,6 +262,7 @@ function renderContentBlock(
case "systemNotification": {
const sn = content as SystemNotificationContent;
const isError = sn.notificationType === "error";
const isCompaction = sn.notificationType === "compaction";
return (
<div
key={`notification-${index}`}
@ -269,10 +270,13 @@ function renderContentBlock(
"rounded-md border p-2 text-xs",
isError
? "border-danger/30 bg-danger/10 text-danger"
: "border-border bg-accent text-muted-foreground",
: isCompaction
? "inline-flex items-center justify-center gap-2 border-success/30 bg-success/10 font-medium text-success"
: "border-border bg-accent text-muted-foreground",
)}
>
{sn.text}
{isCompaction ? <Check className="size-3.5 shrink-0" /> : null}
<span>{sn.text}</span>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatInput } from "../ChatInput";
const mockVoiceDictation = {
isEnabled: true,
isRecording: false,
isTranscribing: false,
isStarting: vi.fn(() => false),
stopRecording: vi.fn(),
toggleRecording: vi.fn(),
};
vi.mock("../hooks/useVoiceDictation", () => ({
useVoiceDictation: () => mockVoiceDictation,
}));
vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
useAgentProviderStatus: () => ({
readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]),
loading: false,
refresh: vi.fn(),
}),
}));
const mockListFilesForMentions = vi.fn<
(roots: string[], maxResults?: number) => Promise<string[]>
>(async () => []);
vi.mock("@/shared/api/system", () => ({
listFilesForMentions: (roots: string[], maxResults?: number) =>
mockListFilesForMentions(roots, maxResults),
}));
describe("ChatInput async send handling", () => {
beforeEach(() => {
mockListFilesForMentions.mockClear();
mockListFilesForMentions.mockResolvedValue([]);
mockVoiceDictation.isEnabled = true;
mockVoiceDictation.isRecording = false;
mockVoiceDictation.isTranscribing = false;
mockVoiceDictation.isStarting.mockReset();
mockVoiceDictation.isStarting.mockReturnValue(false);
mockVoiceDictation.stopRecording.mockReset();
mockVoiceDictation.toggleRecording.mockReset();
});
it("clears the composer after an accepted async send when the draft is unchanged", async () => {
let resolveSend!: (accepted: boolean) => void;
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve;
}),
);
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByRole("textbox");
await user.type(input, "hello");
await user.keyboard("{Enter}");
resolveSend(true);
await waitFor(() => {
expect(input).toHaveValue("");
});
});
it("preserves newer draft text when an async send resolves later", async () => {
let resolveSend!: (accepted: boolean) => void;
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve;
}),
);
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByRole("textbox");
await user.type(input, "hello");
await user.keyboard("{Enter}");
await user.type(input, " world");
resolveSend(true);
await waitFor(() => {
expect(input).toHaveValue("hello world");
});
});
});

View file

@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { ChatInput } from "../ChatInput";
import { ChatInputToolbar } from "../ChatInputToolbar";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import type { Persona } from "@/shared/types/agents";
const mockVoiceDictation = {
@ -57,7 +58,7 @@ const TEST_PERSONAS: Persona[] = [
function StatefulChatInput({
onSend = vi.fn(),
}: {
onSend?: (text: string, personaId?: string) => void;
onSend?: (text: string, personaId?: string) => boolean | Promise<boolean>;
}) {
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
"builtin-solo",
@ -289,6 +290,34 @@ describe("ChatInput", () => {
expect(onCompactContext).toHaveBeenCalledOnce();
});
it("opens compaction settings from the context usage popover", async () => {
const user = userEvent.setup();
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
render(
<ChatInput
onSend={vi.fn()}
selectedProvider="goose"
contextTokens={1536}
contextLimit={8192}
canCompactContext
/>,
);
await user.click(screen.getByRole("button", { name: /context usage/i }));
await user.click(screen.getByRole("button", { name: /settings/i }));
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: OPEN_SETTINGS_EVENT,
detail: { section: "compaction" },
}),
);
dispatchEventSpy.mockRestore();
});
it("hides the context usage control when the context limit is unavailable", () => {
render(
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={0} />,
@ -299,6 +328,21 @@ describe("ChatInput", () => {
).not.toBeInTheDocument();
});
it("hides the context usage control until usage is ready", () => {
render(
<ChatInput
onSend={vi.fn()}
contextTokens={1536}
contextLimit={8192}
isContextUsageReady={false}
/>,
);
expect(
screen.queryByRole("button", { name: /context usage/i }),
).not.toBeInTheDocument();
});
it("shows stop button when streaming", () => {
render(<ChatInput onSend={vi.fn()} onStop={vi.fn()} isStreaming />);
expect(

View file

@ -10,8 +10,6 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn(),
}));
// ── helpers ───────────────────────────────────────────────────────────
function userMessage(text: string, overrides: Partial<Message> = {}): Message {
return {
id: "u1",
@ -35,8 +33,6 @@ function assistantMessage(
};
}
// ── tests ─────────────────────────────────────────────────────────────
describe("MessageBubble", () => {
beforeEach(() => {
useAgentStore.setState({ personas: [] });
@ -81,6 +77,32 @@ describe("MessageBubble", () => {
expect(screen.getByText("hello world")).toBeInTheDocument();
});
it("renders compaction notifications as centered success messages", () => {
const { container } = render(
<MessageBubble
message={{
id: "s1",
role: "system",
created: Date.now(),
content: [
{
type: "systemNotification",
notificationType: "compaction",
text: "Conversation compacted.",
},
],
metadata: {
userVisible: true,
agentVisible: false,
},
}}
/>,
);
expect(screen.getByText("Conversation compacted.")).toBeInTheDocument();
expect(container.querySelector(".text-success")).toBeInTheDocument();
});
it("renders user text inside a muted bubble shell", () => {
const { container } = render(
<MessageBubble message={userMessage("hello world")} />,

View file

@ -65,6 +65,7 @@ const mockController = {
availableProjects: [],
handleProjectChange: vi.fn(),
tokenState: { accumulatedTotal: 0, contextLimit: 0 },
isContextUsageReady: false,
};
vi.mock("@/shared/api/acp", () => ({

View file

@ -104,6 +104,7 @@ function HomeComposer({
}
contextTokens={controller.tokenState.accumulatedTotal}
contextLimit={controller.tokenState.contextLimit}
isContextUsageReady={controller.isContextUsageReady}
/>
);
}

View file

@ -20,6 +20,7 @@ function inventoryModelToOption(
provider: model.family ?? undefined,
providerId: provider?.providerId,
providerName: provider?.providerName,
contextLimit: model.contextLimit ?? undefined,
recommended: model.recommended ?? false,
};
}

View file

@ -0,0 +1,13 @@
export const OPEN_SETTINGS_EVENT = "goose:open-settings";
export function requestOpenSettings(section?: string) {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(
new CustomEvent(OPEN_SETTINGS_EVENT, {
detail: section ? { section } : undefined,
}),
);
}

View file

@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { IconCheck } from "@tabler/icons-react";
import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons";
import { GooseAutoCompactSettings } from "./GooseAutoCompactSettings";
export function CompactionSettings() {
const { t } = useTranslation("settings");
const icon = getProviderIcon("goose", "size-6");
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold font-display tracking-tight">
{t("compaction.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("compaction.description")}
</p>
</div>
<div className="rounded-lg border bg-background p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex size-6 items-center justify-center [&>*]:size-6">
{icon}
</div>
<span className="mt-2 block text-sm font-medium">
{t("compaction.goose.label")}
</span>
<p className="mt-1 text-xs text-muted-foreground">
{t("compaction.goose.description")}
</p>
</div>
<div className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-1 text-xxs font-medium text-success">
<IconCheck className="size-3.5" />
<span>{t("compaction.goose.builtIn")}</span>
</div>
</div>
<div className="mt-4 border-t pt-4">
<GooseAutoCompactSettings />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { type LocalePreference, useLocale } from "@/shared/i18n";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { Separator } from "@/shared/ui/separator";
function SettingRow({
label,
description,
children,
}: {
label: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-8 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{label}</p>
{description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
export function GeneralSettings() {
const { t } = useTranslation("settings");
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
return (
<div>
<h3 className="text-lg font-semibold font-display tracking-tight">
{t("general.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("general.description")}
</p>
<Separator className="my-4" />
<SettingRow
label={t("general.language.label")}
description={t("general.language.description")}
>
<Select
value={preference}
onValueChange={(value) =>
void setLocalePreference(value as LocalePreference)
}
>
<SelectTrigger className="w-full min-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
{t("general.language.system", {
language: systemLocaleLabel,
})}
</SelectItem>
<SelectItem value="en">{t("general.language.english")}</SelectItem>
<SelectItem value="es">{t("general.language.spanish")}</SelectItem>
</SelectContent>
</Select>
</SettingRow>
</div>
);
}

View file

@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocaleFormatting } from "@/shared/i18n";
import {
autoCompactPercentToThreshold,
autoCompactThresholdToPercent,
clampAutoCompactThresholdPercent,
} from "@/features/chat/lib/autoCompact";
import { useAutoCompactPreferences } from "@/features/chat/hooks/useAutoCompactPreferences";
import { Slider } from "@/shared/ui/slider";
export function GooseAutoCompactSettings() {
const { t } = useTranslation("settings");
const { formatNumber } = useLocaleFormatting();
const {
autoCompactThreshold,
isHydrated: isAutoCompactThresholdHydrated,
setAutoCompactThreshold,
} = useAutoCompactPreferences();
const autoCompactThresholdPercent =
autoCompactThresholdToPercent(autoCompactThreshold);
const [draftThresholdPercent, setDraftThresholdPercent] = useState(
autoCompactThresholdPercent,
);
const [isSavingThreshold, setIsSavingThreshold] = useState(false);
const [thresholdError, setThresholdError] = useState<string | null>(null);
const translationKeyPrefix = "compaction.goose.autoCompact";
const autoCompactValueLabel = !isAutoCompactThresholdHydrated
? t(`${translationKeyPrefix}.loading`)
: draftThresholdPercent >= 100
? t(`${translationKeyPrefix}.off`)
: formatNumber(draftThresholdPercent / 100, {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
useEffect(() => {
setDraftThresholdPercent(autoCompactThresholdPercent);
}, [autoCompactThresholdPercent]);
const normalizeThresholdPercent = (value: number | undefined) =>
clampAutoCompactThresholdPercent(value ?? autoCompactThresholdPercent);
const handleThresholdSliderChange = (values: number[]) => {
const nextPercent = normalizeThresholdPercent(values[0]);
setThresholdError(null);
setDraftThresholdPercent(nextPercent);
};
const saveThresholdPercent = async (nextPercent: number) => {
if (isSavingThreshold) {
return;
}
setThresholdError(null);
setDraftThresholdPercent(nextPercent);
if (nextPercent === autoCompactThresholdPercent) {
return;
}
setIsSavingThreshold(true);
try {
await setAutoCompactThreshold(autoCompactPercentToThreshold(nextPercent));
} catch {
setThresholdError(t(`${translationKeyPrefix}.saveError`));
setDraftThresholdPercent(autoCompactThresholdPercent);
} finally {
setIsSavingThreshold(false);
}
};
const handleThresholdSliderCommit = async (values: number[]) => {
const nextPercent = normalizeThresholdPercent(values[0]);
await saveThresholdPercent(nextPercent);
};
return (
<div className="space-y-3">
<div className="min-w-0">
<p className="text-sm font-medium">
{t(`${translationKeyPrefix}.label`)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t(`${translationKeyPrefix}.description`)}
</p>
</div>
<div className="w-full space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{t(`${translationKeyPrefix}.current`)}
</span>
<div className="flex items-center gap-1.5 text-xs text-foreground">
{isSavingThreshold ? (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
) : null}
<span className="shrink-0 font-medium">
{autoCompactValueLabel}
</span>
</div>
</div>
<Slider
value={[draftThresholdPercent]}
min={1}
max={100}
step={1}
onValueChange={handleThresholdSliderChange}
onValueCommit={(values) => {
void handleThresholdSliderCommit(values);
}}
disabled={isSavingThreshold || !isAutoCompactThresholdHydrated}
aria-label={t(`${translationKeyPrefix}.label`)}
/>
<p className="text-[11px] text-muted-foreground">
{t(`${translationKeyPrefix}.helper`)}
</p>
{thresholdError ? (
<p className="text-[11px] text-destructive">{thresholdError}</p>
) : null}
</div>
</div>
);
}

View file

@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { type LocalePreference, useLocale } from "@/shared/i18n";
import { Button, buttonVariants } from "@/shared/ui/button";
import {
AlertDialog,
@ -13,15 +12,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import {
Mic,
Minimize2,
Palette,
Settings2,
FolderKanban,
@ -36,6 +29,8 @@ import { DoctorSettings } from "./DoctorSettings";
import { ProvidersSettings } from "./ProvidersSettings";
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
import { VoiceInputSettings } from "./VoiceInputSettings";
import { GeneralSettings } from "./GeneralSettings";
import { CompactionSettings } from "./CompactionSettings";
import {
listArchivedProjects,
restoreProject,
@ -51,6 +46,7 @@ import type { Session } from "@/shared/types/chat";
const NAV_ITEMS = [
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
{ id: "compaction", labelKey: "nav.compaction", icon: Minimize2 },
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
{ id: "voice", labelKey: "nav.voice", icon: Mic },
{ id: "general", labelKey: "nav.general", icon: Settings2 },
@ -72,7 +68,6 @@ export function SettingsModal({
initialSection = "appearance",
}: SettingsModalProps) {
const { t } = useTranslation(["settings", "common"]);
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [isLoaded, setIsLoaded] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
@ -243,55 +238,11 @@ export function SettingsModal({
>
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "providers" && <ProvidersSettings />}
{activeSection === "compaction" && <CompactionSettings />}
{activeSection === "extensions" && <ExtensionsSettings />}
{activeSection === "voice" && <VoiceInputSettings />}
{activeSection === "doctor" && <DoctorSettings />}
{activeSection === "general" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold font-display tracking-tight">
{t("general.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("general.description")}
</p>
</div>
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold">
{t("general.language.label")}
</h4>
<p className="mt-1 text-xs text-muted-foreground">
{t("general.language.description")}
</p>
</div>
<Select
value={preference}
onValueChange={(value) =>
void setLocalePreference(value as LocalePreference)
}
>
<SelectTrigger className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
{t("general.language.system", {
language: systemLocaleLabel,
})}
</SelectItem>
<SelectItem value="en">
{t("general.language.english")}
</SelectItem>
<SelectItem value="es">
{t("general.language.spanish")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{activeSection === "general" && <GeneralSettings />}
{activeSection === "projects" && (
<div className="space-y-6">
<div>

View file

@ -0,0 +1,78 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GooseAutoCompactSettings } from "../GooseAutoCompactSettings";
const mockSetAutoCompactThreshold = vi.fn();
const mockUseAutoCompactPreferences = vi.fn();
vi.mock("@/shared/i18n", async () => {
const actual =
await vi.importActual<typeof import("@/shared/i18n")>("@/shared/i18n");
return {
...actual,
useLocaleFormatting: () => ({
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat("en-US", options).format(value),
}),
};
});
vi.mock("@/features/chat/hooks/useAutoCompactPreferences", () => ({
useAutoCompactPreferences: () => mockUseAutoCompactPreferences(),
}));
describe("GooseAutoCompactSettings", () => {
beforeEach(() => {
mockSetAutoCompactThreshold.mockReset();
mockSetAutoCompactThreshold.mockResolvedValue(undefined);
mockUseAutoCompactPreferences.mockReset();
mockUseAutoCompactPreferences.mockReturnValue({
autoCompactThreshold: 0.8,
isHydrated: true,
setAutoCompactThreshold: mockSetAutoCompactThreshold,
});
});
it("updates the auto-compaction threshold from the slider", async () => {
const user = userEvent.setup();
render(<GooseAutoCompactSettings />);
const slider = screen.getByRole("slider", {
name: /auto-compact context/i,
});
slider.focus();
await user.keyboard("{ArrowRight}");
await waitFor(() =>
expect(mockSetAutoCompactThreshold).toHaveBeenCalledWith(0.81),
);
});
it("keeps the slider interactive when auto-compaction is off", async () => {
const user = userEvent.setup();
mockUseAutoCompactPreferences.mockReturnValue({
autoCompactThreshold: 1,
isHydrated: true,
setAutoCompactThreshold: mockSetAutoCompactThreshold,
});
render(<GooseAutoCompactSettings />);
const slider = screen.getByRole("slider", {
name: /auto-compact context/i,
});
expect(screen.getByText("Off")).toBeInTheDocument();
expect(slider).not.toHaveAttribute("aria-disabled", "true");
slider.focus();
await user.keyboard("{ArrowLeft}");
await waitFor(() =>
expect(mockSetAutoCompactThreshold).toHaveBeenCalledWith(0.99),
);
});
});

View file

@ -50,6 +50,7 @@ describe("acpNotificationHandler", () => {
.getSessionRuntime("draft-session-1");
expect(runtime.tokenState.accumulatedTotal).toBe(512);
expect(runtime.tokenState.contextLimit).toBe(8192);
expect(runtime.hasUsageSnapshot).toBe(true);
});
it("does not buffer non-usage updates before the local session mapping exists", async () => {

View file

@ -120,6 +120,9 @@
"title": "Mention an agent",
"filesTitle": "Files"
},
"notifications": {
"compactionComplete": "Conversation compacted. Older context was summarized."
},
"message": {
"copied": "Copied",
"defaultImageAlt": "Attached",
@ -167,6 +170,7 @@
"model": "Model",
"allModels": "All models",
"searchModels": "Search models...",
"settings": "Settings",
"recommended": "Recommended",
"noModelsAvailable": "No models available",
"noSearchResults": "No matching models",

View file

@ -51,6 +51,24 @@
"standalone": "Standalone chat"
}
},
"compaction": {
"description": "Manage how Goose trims older context in long-running chats.",
"goose": {
"autoCompact": {
"current": "Threshold",
"description": "Choose when Goose should compact older context before replying. This applies to all Goose chats.",
"helper": "Set to 100% to turn auto-compaction off.",
"label": "Auto-compact context",
"loading": "Loading...",
"off": "Off",
"saveError": "Failed to save auto-compaction setting."
},
"builtIn": "Built in",
"description": "Goose-specific context controls for long-running chats.",
"label": "Goose (Agent)"
},
"title": "Compaction"
},
"deleteProject": {
"description": "Are you sure you want to permanently delete \"{{name}}\"? This cannot be undone.",
"title": "Delete project permanently?"
@ -172,6 +190,7 @@
"about": "About",
"appearance": "Appearance",
"chats": "Chats",
"compaction": "Compaction",
"doctor": "Doctor",
"general": "General",
"projects": "Projects",

View file

@ -120,6 +120,9 @@
"title": "Menciona un agente",
"filesTitle": "Archivos"
},
"notifications": {
"compactionComplete": "Conversacion compactada. El contexto anterior se resumio."
},
"message": {
"copied": "Copiado",
"defaultImageAlt": "Adjunto",
@ -167,6 +170,7 @@
"model": "Modelo",
"allModels": "Todos los modelos",
"searchModels": "Buscar modelos...",
"settings": "Configuración",
"recommended": "Recomendados",
"noModelsAvailable": "No hay modelos disponibles",
"noSearchResults": "Sin resultados",

View file

@ -51,6 +51,24 @@
"standalone": "Chat independiente"
}
},
"compaction": {
"description": "Administra cómo Goose compacta el contexto anterior en chats largos.",
"goose": {
"autoCompact": {
"current": "Umbral",
"description": "Elige cuándo Goose debe compactar el contexto anterior antes de responder. Esto se aplica a todos los chats de Goose.",
"helper": "Ajústalo al 100% para desactivar la compactación automática.",
"label": "Compactación automática del contexto",
"loading": "Cargando...",
"off": "Desactivado",
"saveError": "No se pudo guardar la configuración de compactación automática."
},
"builtIn": "Integrado",
"description": "Controles de contexto específicos de Goose para chats largos.",
"label": "Goose (Agente)"
},
"title": "Compactación"
},
"deleteProject": {
"description": "¿Seguro que quieres eliminar permanentemente \"{{name}}\"? Esta acción no se puede deshacer.",
"title": "¿Eliminar el proyecto de forma permanente?"
@ -172,6 +190,7 @@
"about": "Acerca de",
"appearance": "Apariencia",
"chats": "Chats",
"compaction": "Compactación",
"doctor": "Diagnóstico",
"general": "General",
"projects": "Proyectos",

View file

@ -0,0 +1,10 @@
export function isPromiseLike<T = unknown>(
value: unknown,
): value is PromiseLike<T> {
return (
(typeof value === "object" || typeof value === "function") &&
value !== null &&
"then" in value &&
typeof value.then === "function"
);
}

View file

@ -34,6 +34,7 @@ export const INITIAL_TOKEN_STATE: TokenState = {
export interface SessionChatRuntime {
chatState: ChatState;
tokenState: TokenState;
hasUsageSnapshot: boolean;
streamingMessageId: string | null;
pendingAssistantProviderId: string | null;
error: string | null;
@ -43,6 +44,7 @@ export interface SessionChatRuntime {
export const INITIAL_SESSION_CHAT_RUNTIME: SessionChatRuntime = {
chatState: "idle",
tokenState: INITIAL_TOKEN_STATE,
hasUsageSnapshot: false,
streamingMessageId: null,
pendingAssistantProviderId: null,
error: null,

View file

@ -11,6 +11,9 @@ function Slider({
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const thumbAriaLabel = props["aria-label"];
const thumbAriaLabelledBy = props["aria-labelledby"];
const isDisabled = props.disabled === true;
const _values = React.useMemo(
() =>
Array.isArray(value)
@ -51,6 +54,9 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
aria-label={thumbAriaLabel}
aria-labelledby={thumbAriaLabelledBy}
aria-disabled={isDisabled || undefined}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}