From 469c74d8bcae0d0be7e7f2fc69c345dfc615b377 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 21 Apr 2026 18:10:22 -0700 Subject: [PATCH] feat: extend goose2 context window ux with auto-compaction (#8721) Signed-off-by: Taylor Ho --- ui/goose2/scripts/check-file-sizes.mjs | 22 +- ui/goose2/src/app/AppShell.tsx | 47 ++- .../useAutoCompactPreferences.test.ts | 121 ++++++ .../__tests__/useChat.compaction.test.ts | 64 ++- .../useChat.personaPreparation.test.ts | 103 +++++ ...seChatSessionController.compaction.test.ts | 399 ++++++++++++++++++ .../useChatSessionController.test.ts | 2 +- .../hooks/__tests__/useMessageQueue.test.ts | 46 ++ .../chat/hooks/useAgentModelPickerState.ts | 1 + .../chat/hooks/useAutoCompactPreferences.ts | 125 ++++++ ui/goose2/src/features/chat/hooks/useChat.ts | 188 +++++---- .../chat/hooks/useChatSessionController.ts | 167 +++++++- .../features/chat/hooks/useMessageQueue.ts | 126 +++++- .../chat/hooks/useResolvedAgentModelPicker.ts | 25 +- .../features/chat/hooks/useVoiceDictation.ts | 29 +- .../lib/__tests__/replaySanitizer.test.ts | 65 +++ .../src/features/chat/lib/autoCompact.ts | 96 +++++ .../src/features/chat/lib/replaySanitizer.ts | 31 ++ .../chat/stores/__tests__/chatStore.test.ts | 2 + .../src/features/chat/stores/chatStore.ts | 20 + ui/goose2/src/features/chat/types.ts | 7 +- ui/goose2/src/features/chat/ui/ChatInput.tsx | 45 +- .../src/features/chat/ui/ChatInputToolbar.tsx | 69 ++- ui/goose2/src/features/chat/ui/ChatView.tsx | 10 +- .../src/features/chat/ui/MessageBubble.tsx | 8 +- .../ui/__tests__/ChatInput.asyncSend.test.tsx | 93 ++++ .../chat/ui/__tests__/ChatInput.test.tsx | 46 +- .../chat/ui/__tests__/MessageBubble.test.tsx | 30 +- .../src/features/home/ui/HomeScreen.test.tsx | 1 + ui/goose2/src/features/home/ui/HomeScreen.tsx | 1 + .../providers/hooks/useProviderInventory.ts | 1 + .../features/settings/lib/settingsEvents.ts | 13 + .../settings/ui/CompactionSettings.tsx | 47 +++ .../features/settings/ui/GeneralSettings.tsx | 75 ++++ .../settings/ui/GooseAutoCompactSettings.tsx | 128 ++++++ .../features/settings/ui/SettingsModal.tsx | 61 +-- .../GooseAutoCompactSettings.test.tsx | 78 ++++ .../shared/api/acpNotificationHandler.test.ts | 1 + .../src/shared/i18n/locales/en/chat.json | 4 + .../src/shared/i18n/locales/en/settings.json | 19 + .../src/shared/i18n/locales/es/chat.json | 4 + .../src/shared/i18n/locales/es/settings.json | 19 + ui/goose2/src/shared/lib/isPromiseLike.ts | 10 + ui/goose2/src/shared/types/chat.ts | 2 + ui/goose2/src/shared/ui/slider.tsx | 6 + 45 files changed, 2226 insertions(+), 231 deletions(-) create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts create mode 100644 ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts create mode 100644 ui/goose2/src/features/chat/lib/autoCompact.ts create mode 100644 ui/goose2/src/features/chat/lib/replaySanitizer.ts create mode 100644 ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx create mode 100644 ui/goose2/src/features/settings/lib/settingsEvents.ts create mode 100644 ui/goose2/src/features/settings/ui/CompactionSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/GeneralSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/__tests__/GooseAutoCompactSettings.test.tsx create mode 100644 ui/goose2/src/shared/lib/isPromiseLike.ts diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 15460b7eaf..1cda1e5664 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -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, diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index f61b4b516a..41f3c518c9 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -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([ + "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 } = diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts new file mode 100644 index 0000000000..174d4ddf18 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts @@ -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); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts index 09b1f6fd2f..3d6ef86be7 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts @@ -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; + let compactPromise!: Promise; 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; - let secondCompact!: Promise; + let firstCompact!: Promise; + let secondCompact!: Promise; await act(async () => { firstCompact = result.current.compactConversation(); secondCompact = result.current.compactConversation(); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts new file mode 100644 index 0000000000..a4e19cfc0a --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts @@ -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, + }); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts new file mode 100644 index 0000000000..fb00e14235 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts @@ -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) + | 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, + ); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 314cbf8c1c..cd2f7990ae 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -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", diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts index 99917d59b5..9ef2a11901 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts @@ -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", + }); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts index 5f93140ac5..6534c9dab6 100644 --- a/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts +++ b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts @@ -155,6 +155,7 @@ export function useAgentModelPickerState({ provider: selectedModel?.provider, providerId: selectedModel?.providerId, providerName: selectedModel?.providerName, + contextLimit: selectedModel?.contextLimit, recommended: selectedModel?.recommended, }); }, diff --git a/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts b/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts new file mode 100644 index 0000000000..49bae104d4 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 2279ff664e..57cc0b0322 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -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; + ensurePrepared?: (personaId?: string) => Promise; }, ) { 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; diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index 6a8af51cdd..fcbdc7435d 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -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((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, diff --git a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts index bc5859039d..09138e506c 100644 --- a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts +++ b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts @@ -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, ) { 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(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[]) => { diff --git a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts index de65b34399..513533007e 100644 --- a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -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 diff --git a/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts index cfd2d67e81..c2be67afb5 100644 --- a/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts +++ b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts @@ -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; 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(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(); diff --git a/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts b/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts new file mode 100644 index 0000000000..24ddbc4acf --- /dev/null +++ b/ui/goose2/src/features/chat/lib/__tests__/replaySanitizer.test.ts @@ -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?", + ), + ]); + }); +}); diff --git a/ui/goose2/src/features/chat/lib/autoCompact.ts b/ui/goose2/src/features/chat/lib/autoCompact.ts new file mode 100644 index 0000000000..eb172ace76 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/autoCompact.ts @@ -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); +} diff --git a/ui/goose2/src/features/chat/lib/replaySanitizer.ts b/ui/goose2/src/features/chat/lib/replaySanitizer.ts new file mode 100644 index 0000000000..117e433455 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/replaySanitizer.ts @@ -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)); +} diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts index 72873531b1..8f35b3c868 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts @@ -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", () => { diff --git a/ui/goose2/src/features/chat/stores/chatStore.ts b/ui/goose2/src/features/chat/stores/chatStore.ts index f7781b8ef9..19c983e074 100644 --- a/ui/goose2/src/features/chat/stores/chatStore.ts +++ b/ui/goose2/src/features/chat/stores/chatStore.ts @@ -103,6 +103,11 @@ interface ChatStoreActions { markSessionRead: (sessionId: string) => void; markSessionUnread: (sessionId: string) => void; updateTokenState: (sessionId: string, state: Partial) => 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((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((set, get) => ({ ...(state.sessionStateById[sessionId] ?? createInitialSessionRuntime()), tokenState: { ...INITIAL_TOKEN_STATE }, + hasUsageSnapshot: false, }, }, })), diff --git a/ui/goose2/src/features/chat/types.ts b/ui/goose2/src/features/chat/types.ts index 2fa09b4d21..f8ac15e2a2 100644 --- a/ui/goose2/src/features/chat/types.ts +++ b/ui/goose2/src/features/chat/types.ts @@ -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; onStop?: () => void; isStreaming?: boolean; disabled?: boolean; @@ -56,7 +57,9 @@ export interface ChatInputProps { }) => void; contextTokens?: number; contextLimit?: number; - onCompactContext?: () => void | Promise; + isContextUsageReady?: boolean; + onCompactContext?: () => Promise | undefined; canCompactContext?: boolean; isCompactingContext?: boolean; + supportsCompactionControls?: boolean; } diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index aa10fe35cd..0953bdda80 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -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(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} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 7eec7f847d..bde1f4c894 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -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; + onCompactContext?: () => Promise | 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(); @@ -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 (
{/* Left side: pickers */} @@ -286,7 +308,7 @@ export function ChatInputToolbar({ /> )} - {contextLimit > 0 && ( + {showContextUsage && (
{t("toolbar.contextWindow")} @@ -336,18 +358,33 @@ export function ChatInputToolbar({
{usedPercentLabel}
- + {compactionControlsSupported ? ( +
+ + +
+ ) : null} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index d48d540db1..38ed5186e6 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -111,7 +111,10 @@ export function ChatView({ diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index e82b45bffd..f5fda520d3 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -262,6 +262,7 @@ function renderContentBlock( case "systemNotification": { const sn = content as SystemNotificationContent; const isError = sn.notificationType === "error"; + const isCompaction = sn.notificationType === "compaction"; return (
- {sn.text} + {isCompaction ? : null} + {sn.text}
); } diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx new file mode 100644 index 0000000000..1f8dbc3064 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx @@ -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 +>(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((resolve) => { + resolveSend = resolve; + }), + ); + const user = userEvent.setup(); + render(); + + 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((resolve) => { + resolveSend = resolve; + }), + ); + const user = userEvent.setup(); + render(); + + 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"); + }); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index b67f93bf88..f59623df07 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -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; }) { const [selectedPersonaId, setSelectedPersonaId] = useState( "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( + , + ); + + 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( , @@ -299,6 +328,21 @@ describe("ChatInput", () => { ).not.toBeInTheDocument(); }); + it("hides the context usage control until usage is ready", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /context usage/i }), + ).not.toBeInTheDocument(); + }); + it("shows stop button when streaming", () => { render(); expect( diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx index 85cd06b0e3..5f2dff79fc 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -10,8 +10,6 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn(), })); -// ── helpers ─────────────────────────────────────────────────────────── - function userMessage(text: string, overrides: Partial = {}): 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( + , + ); + + expect(screen.getByText("Conversation compacted.")).toBeInTheDocument(); + expect(container.querySelector(".text-success")).toBeInTheDocument(); + }); + it("renders user text inside a muted bubble shell", () => { const { container } = render( , diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx index 170e0ea438..3492b20abb 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -65,6 +65,7 @@ const mockController = { availableProjects: [], handleProjectChange: vi.fn(), tokenState: { accumulatedTotal: 0, contextLimit: 0 }, + isContextUsageReady: false, }; vi.mock("@/shared/api/acp", () => ({ diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx index c625ed8ec4..77b1b2e477 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -104,6 +104,7 @@ function HomeComposer({ } contextTokens={controller.tokenState.accumulatedTotal} contextLimit={controller.tokenState.contextLimit} + isContextUsageReady={controller.isContextUsageReady} /> ); } diff --git a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts index f8f0018327..ddf0b01343 100644 --- a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts +++ b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts @@ -20,6 +20,7 @@ function inventoryModelToOption( provider: model.family ?? undefined, providerId: provider?.providerId, providerName: provider?.providerName, + contextLimit: model.contextLimit ?? undefined, recommended: model.recommended ?? false, }; } diff --git a/ui/goose2/src/features/settings/lib/settingsEvents.ts b/ui/goose2/src/features/settings/lib/settingsEvents.ts new file mode 100644 index 0000000000..003f331e9c --- /dev/null +++ b/ui/goose2/src/features/settings/lib/settingsEvents.ts @@ -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, + }), + ); +} diff --git a/ui/goose2/src/features/settings/ui/CompactionSettings.tsx b/ui/goose2/src/features/settings/ui/CompactionSettings.tsx new file mode 100644 index 0000000000..61f97505c6 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/CompactionSettings.tsx @@ -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 ( +
+
+

+ {t("compaction.title")} +

+

+ {t("compaction.description")} +

+
+ +
+
+
+
+ {icon} +
+ + {t("compaction.goose.label")} + +

+ {t("compaction.goose.description")} +

+
+ +
+ + {t("compaction.goose.builtIn")} +
+
+ +
+ +
+
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/GeneralSettings.tsx b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx new file mode 100644 index 0000000000..dae850a45e --- /dev/null +++ b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx @@ -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 ( +
+
+

{label}

+ {description ? ( +

{description}

+ ) : null} +
+
{children}
+
+ ); +} + +export function GeneralSettings() { + const { t } = useTranslation("settings"); + const { preference, setLocalePreference, systemLocaleLabel } = useLocale(); + + return ( +
+

+ {t("general.title")} +

+

+ {t("general.description")} +

+ + + + + + +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx b/ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx new file mode 100644 index 0000000000..73dcad6889 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx @@ -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(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 ( +
+
+

+ {t(`${translationKeyPrefix}.label`)} +

+

+ {t(`${translationKeyPrefix}.description`)} +

+
+ +
+
+ + {t(`${translationKeyPrefix}.current`)} + +
+ {isSavingThreshold ? ( + + ) : null} + + {autoCompactValueLabel} + +
+
+ + { + void handleThresholdSliderCommit(values); + }} + disabled={isSavingThreshold || !isAutoCompactThresholdHydrated} + aria-label={t(`${translationKeyPrefix}.label`)} + /> + +

+ {t(`${translationKeyPrefix}.helper`)} +

+ + {thresholdError ? ( +

{thresholdError}

+ ) : null} +
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 03400ccef2..0544f0f6e0 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -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(initialSection); const [isLoaded, setIsLoaded] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); @@ -243,55 +238,11 @@ export function SettingsModal({ > {activeSection === "appearance" && } {activeSection === "providers" && } + {activeSection === "compaction" && } {activeSection === "extensions" && } {activeSection === "voice" && } {activeSection === "doctor" && } - {activeSection === "general" && ( -
-
-

- {t("general.title")} -

-

- {t("general.description")} -

-
- -
-
-

- {t("general.language.label")} -

-

- {t("general.language.description")} -

-
- -
-
- )} + {activeSection === "general" && } {activeSection === "projects" && (
diff --git a/ui/goose2/src/features/settings/ui/__tests__/GooseAutoCompactSettings.test.tsx b/ui/goose2/src/features/settings/ui/__tests__/GooseAutoCompactSettings.test.tsx new file mode 100644 index 0000000000..4a5680641a --- /dev/null +++ b/ui/goose2/src/features/settings/ui/__tests__/GooseAutoCompactSettings.test.tsx @@ -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("@/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(); + + 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(); + + 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), + ); + }); +}); diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts index 1a3df9e7e1..c2acbc8878 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.test.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts @@ -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 () => { diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index 4fdb53fb39..dca7bef470 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -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", diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index d9733f3800..53089aa0fa 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -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", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index 92f1917f24..ee6bd16783 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -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", diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index 16e33a960a..6bce4a833f 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -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", diff --git a/ui/goose2/src/shared/lib/isPromiseLike.ts b/ui/goose2/src/shared/lib/isPromiseLike.ts new file mode 100644 index 0000000000..82fe667311 --- /dev/null +++ b/ui/goose2/src/shared/lib/isPromiseLike.ts @@ -0,0 +1,10 @@ +export function isPromiseLike( + value: unknown, +): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + "then" in value && + typeof value.then === "function" + ); +} diff --git a/ui/goose2/src/shared/types/chat.ts b/ui/goose2/src/shared/types/chat.ts index e5cf618291..bf5d4b4b14 100644 --- a/ui/goose2/src/shared/types/chat.ts +++ b/ui/goose2/src/shared/types/chat.ts @@ -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, diff --git a/ui/goose2/src/shared/ui/slider.tsx b/ui/goose2/src/shared/ui/slider.tsx index 74acfdc18e..b8040576ef 100644 --- a/ui/goose2/src/shared/ui/slider.tsx +++ b/ui/goose2/src/shared/ui/slider.tsx @@ -11,6 +11,9 @@ function Slider({ max = 100, ...props }: React.ComponentProps) { + 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({ ))}