mirror of
https://github.com/block/goose.git
synced 2026-04-26 10:40:45 +00:00
feat: extend goose2 context window ux with auto-compaction (#8721)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
parent
7e2fb3ee5c
commit
469c74d8bc
45 changed files with 2226 additions and 231 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { archiveProject } from "@/features/projects/api/projects";
|
|||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
|
||||
import type { SectionId } from "@/features/settings/ui/SettingsModal";
|
||||
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
|
||||
import { TopBar } from "./ui/TopBar";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer";
|
||||
|
||||
export type AppView =
|
||||
| "home"
|
||||
|
|
@ -44,6 +46,18 @@ const SIDEBAR_MIN_WIDTH = 180;
|
|||
const SIDEBAR_MAX_WIDTH = 380;
|
||||
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 48;
|
||||
const SETTINGS_SECTIONS = new Set<SectionId>([
|
||||
"appearance",
|
||||
"providers",
|
||||
"compaction",
|
||||
"extensions",
|
||||
"voice",
|
||||
"general",
|
||||
"projects",
|
||||
"chats",
|
||||
"doctor",
|
||||
"about",
|
||||
]);
|
||||
export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
|
||||
|
|
@ -103,14 +117,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||
const tFlush = performance.now();
|
||||
useChatStore.getState().setSessionLoading(sessionId, false);
|
||||
const buffer = getAndDeleteReplayBuffer(sessionId);
|
||||
const replayMessages = buffer
|
||||
? sanitizeReplayMessages(buffer)
|
||||
: undefined;
|
||||
const replayStats = getReplayPerf(sessionId);
|
||||
clearReplayPerf(sessionId);
|
||||
if (buffer && buffer.length > 0) {
|
||||
useChatStore.getState().setMessages(sessionId, buffer);
|
||||
if (replayMessages) {
|
||||
useChatStore.getState().setMessages(sessionId, replayMessages);
|
||||
}
|
||||
const t2 = performance.now();
|
||||
perfLog(
|
||||
`[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`,
|
||||
`[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${replayMessages?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to load session messages:", err);
|
||||
|
|
@ -360,6 +377,30 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||
setSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettingsEvent = (event: Event) => {
|
||||
const section = (event as CustomEvent<{ section?: string }>).detail
|
||||
?.section;
|
||||
if (section && SETTINGS_SECTIONS.has(section as SectionId)) {
|
||||
openSettings(section as SectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
openSettings();
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
OPEN_SETTINGS_EVENT,
|
||||
handleOpenSettingsEvent as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
OPEN_SETTINGS_EVENT,
|
||||
handleOpenSettingsEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [openSettings]);
|
||||
|
||||
const handleArchiveChat = useCallback(
|
||||
async (sessionId: string) => {
|
||||
const { activeSessionId: currentActiveSessionId } =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -102,10 +102,27 @@ describe("useChat compaction", () => {
|
|||
const messages = useChatStore.getState().messagesBySession["session-1"];
|
||||
const runtime = useChatStore.getState().getSessionRuntime("session-1");
|
||||
|
||||
expect(messages).toEqual([
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0]).toEqual(
|
||||
createTextMessage("user-1", "user", "Before compact"),
|
||||
);
|
||||
expect(messages[1]).toEqual(
|
||||
createTextMessage("assistant-1", "assistant", "After compact"),
|
||||
]);
|
||||
);
|
||||
expect(messages[2]).toMatchObject({
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "systemNotification",
|
||||
notificationType: "compaction",
|
||||
text: "Conversation compacted. Older context was summarized.",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: false,
|
||||
},
|
||||
});
|
||||
expect(runtime.chatState).toBe("idle");
|
||||
expect(runtime.error).toBeNull();
|
||||
expect(useChatStore.getState().loadingSessionIds.has("session-1")).toBe(
|
||||
|
|
@ -113,6 +130,43 @@ describe("useChat compaction", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("prepares and compacts the override persona session", async () => {
|
||||
let preparedPersonaId: string | undefined;
|
||||
const ensurePrepared = vi.fn(async (personaId?: string) => {
|
||||
preparedPersonaId = personaId;
|
||||
});
|
||||
mockGetGooseSessionId.mockImplementation(
|
||||
(_sessionId: string, personaId?: string) =>
|
||||
personaId === "persona-a" && preparedPersonaId === "persona-a"
|
||||
? "goose-session-a"
|
||||
: null,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChat(
|
||||
"session-1",
|
||||
undefined,
|
||||
undefined,
|
||||
{ id: "persona-b", name: "Persona B" },
|
||||
{ ensurePrepared },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.compactConversation({ id: "persona-a" });
|
||||
});
|
||||
|
||||
expect(ensurePrepared).toHaveBeenCalledWith("persona-a");
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact", {
|
||||
personaId: "persona-a",
|
||||
});
|
||||
expect(mockAcpLoadSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
"goose-session-a",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks new sends while compaction is in flight", async () => {
|
||||
mockGetGooseSessionId.mockReturnValue("goose-session-1");
|
||||
const compactDeferred = createDeferredPromise();
|
||||
|
|
@ -123,7 +177,7 @@ describe("useChat compaction", () => {
|
|||
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
let compactPromise!: Promise<void>;
|
||||
let compactPromise!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
compactPromise = result.current.compactConversation();
|
||||
await Promise.resolve();
|
||||
|
|
@ -170,8 +224,8 @@ describe("useChat compaction", () => {
|
|||
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
let firstCompact!: Promise<void>;
|
||||
let secondCompact!: Promise<void>;
|
||||
let firstCompact!: Promise<unknown>;
|
||||
let secondCompact!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
firstCompact = result.current.compactConversation();
|
||||
secondCompact = result.current.compactConversation();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { useChatStore } from "../../stores/chatStore";
|
||||
import { useChatSessionStore } from "../../stores/chatSessionStore";
|
||||
|
||||
const mockSendMessage = vi.fn();
|
||||
const mockCompactConversation = vi.fn();
|
||||
const mockSetSelectedProvider = vi.fn();
|
||||
const mockResolveSessionCwd = vi.fn();
|
||||
const mockHandleProviderChange = vi.fn();
|
||||
const mockHandleModelChange = vi.fn();
|
||||
let mockSelectedAgentId = "goose";
|
||||
const INITIAL_TOKEN_STATE = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
accumulatedInput: 0,
|
||||
accumulatedOutput: 0,
|
||||
accumulatedTotal: 0,
|
||||
contextLimit: 0,
|
||||
};
|
||||
let mockTokenState = { ...INITIAL_TOKEN_STATE };
|
||||
let capturedQueuedSend:
|
||||
| ((
|
||||
text: string,
|
||||
overridePersona?: { id: string; name?: string },
|
||||
attachments?: unknown[],
|
||||
) => boolean | Promise<boolean>)
|
||||
| null = null;
|
||||
|
||||
vi.mock("../useChat", () => ({
|
||||
useChat: () => ({
|
||||
messages: [],
|
||||
chatState: "idle",
|
||||
tokenState: mockTokenState,
|
||||
sendMessage: (...args: unknown[]) => mockSendMessage(...args),
|
||||
compactConversation: (...args: unknown[]) =>
|
||||
mockCompactConversation(...args),
|
||||
stopStreaming: vi.fn(),
|
||||
streamingMessageId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../useMessageQueue", () => ({
|
||||
useMessageQueue: (...args: unknown[]) => {
|
||||
capturedQueuedSend = args[2] as typeof capturedQueuedSend;
|
||||
return {
|
||||
queuedMessage: null,
|
||||
enqueue: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../useAutoCompactPreferences", () => ({
|
||||
useAutoCompactPreferences: () => ({
|
||||
autoCompactThreshold: 0.8,
|
||||
isHydrated: true,
|
||||
setAutoCompactThreshold: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../useResolvedAgentModelPicker", () => ({
|
||||
useResolvedAgentModelPicker: () => ({
|
||||
selectedAgentId: mockSelectedAgentId,
|
||||
pickerAgents: [{ id: "goose", label: "Goose" }],
|
||||
availableModels: [],
|
||||
modelsLoading: false,
|
||||
modelStatusMessage: null,
|
||||
handleProviderChange: (providerId: string) =>
|
||||
mockHandleProviderChange(providerId),
|
||||
handleModelChange: (modelId: string) => mockHandleModelChange(modelId),
|
||||
effectiveModelSelection: {
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "openai",
|
||||
source: "explicit" as const,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/agents/hooks/useProviderSelection", () => ({
|
||||
useProviderSelection: () => ({
|
||||
providers: [
|
||||
{ id: "goose", label: "Goose" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
{ id: "anthropic", label: "Anthropic" },
|
||||
],
|
||||
providersLoading: false,
|
||||
selectedProvider: useAgentStore.getState().selectedProvider ?? "openai",
|
||||
setSelectedProvider: (...args: unknown[]) =>
|
||||
mockSetSelectedProvider(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/projects/lib/sessionCwdSelection", () => ({
|
||||
resolveSessionCwd: (...args: unknown[]) => mockResolveSessionCwd(...args),
|
||||
}));
|
||||
|
||||
import { useChatSessionController } from "../useChatSessionController";
|
||||
|
||||
describe("useChatSessionController compaction behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCompactConversation.mockResolvedValue("completed");
|
||||
mockResolveSessionCwd.mockResolvedValue("/tmp/project");
|
||||
mockTokenState = { ...INITIAL_TOKEN_STATE };
|
||||
capturedQueuedSend = null;
|
||||
mockSelectedAgentId = "goose";
|
||||
|
||||
useAgentStore.setState({
|
||||
personas: [],
|
||||
personasLoading: false,
|
||||
agents: [],
|
||||
agentsLoading: false,
|
||||
providers: [],
|
||||
providersLoading: false,
|
||||
selectedProvider: "openai",
|
||||
activeAgentId: null,
|
||||
isLoading: false,
|
||||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
});
|
||||
|
||||
useProjectStore.setState({
|
||||
projects: [],
|
||||
loading: false,
|
||||
activeProjectId: null,
|
||||
});
|
||||
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
draftsBySession: {},
|
||||
queuedMessageBySession: {},
|
||||
scrollTargetMessageBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: true,
|
||||
});
|
||||
|
||||
useChatSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: "session-1",
|
||||
title: "Chat",
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4o",
|
||||
modelName: "GPT-4o",
|
||||
createdAt: "2026-04-20T00:00:00.000Z",
|
||||
updatedAt: "2026-04-20T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
},
|
||||
],
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
hasHydratedSessions: true,
|
||||
contextPanelOpenBySession: {},
|
||||
activeWorkspaceBySession: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("hides context usage until a fresh usage snapshot exists after switching models", () => {
|
||||
const store = useChatStore.getState();
|
||||
store.replaceTokenState(
|
||||
"session-1",
|
||||
{
|
||||
...INITIAL_TOKEN_STATE,
|
||||
contextLimit: 400_000,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSessionController({ sessionId: "session-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("claude-sonnet-4");
|
||||
});
|
||||
|
||||
const runtime = useChatStore.getState().getSessionRuntime("session-1");
|
||||
expect(runtime.hasUsageSnapshot).toBe(false);
|
||||
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
|
||||
});
|
||||
|
||||
it("hides context usage after switching models even when a snapshot existed", () => {
|
||||
const store = useChatStore.getState();
|
||||
store.replaceTokenState(
|
||||
"session-1",
|
||||
{
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 12_000,
|
||||
contextLimit: 400_000,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSessionController({ sessionId: "session-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("claude-sonnet-4");
|
||||
});
|
||||
|
||||
const runtime = useChatStore.getState().getSessionRuntime("session-1");
|
||||
expect(runtime.hasUsageSnapshot).toBe(false);
|
||||
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
|
||||
});
|
||||
|
||||
it("hides pending home context usage after switching models", () => {
|
||||
const store = useChatStore.getState();
|
||||
store.replaceTokenState(
|
||||
"__home_pending__",
|
||||
{
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 12_000,
|
||||
contextLimit: 400_000,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSessionController({ sessionId: null }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("claude-sonnet-4");
|
||||
});
|
||||
|
||||
const runtime = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime("__home_pending__");
|
||||
expect(runtime.hasUsageSnapshot).toBe(false);
|
||||
expect(runtime.tokenState).toEqual(INITIAL_TOKEN_STATE);
|
||||
});
|
||||
|
||||
it("auto-compacts goose sessions before sending when the threshold is exceeded", async () => {
|
||||
mockTokenState = {
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 8_500,
|
||||
contextLimit: 10_000,
|
||||
};
|
||||
useChatStore
|
||||
.getState()
|
||||
.replaceTokenState("session-1", mockTokenState, true);
|
||||
useChatSessionStore.getState().updateSession("session-1", {
|
||||
providerId: "goose",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSessionController({ sessionId: "session-1" }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend("hello");
|
||||
});
|
||||
|
||||
expect(mockCompactConversation).toHaveBeenCalledOnce();
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined);
|
||||
expect(mockCompactConversation.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockSendMessage.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps compaction enabled for goose agent sessions backed by model providers", async () => {
|
||||
mockTokenState = {
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 8_500,
|
||||
contextLimit: 10_000,
|
||||
};
|
||||
useChatStore
|
||||
.getState()
|
||||
.replaceTokenState("session-1", mockTokenState, true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSessionController({ sessionId: "session-1" }),
|
||||
);
|
||||
|
||||
expect(result.current.selectedProvider).toBe("goose");
|
||||
expect(result.current.supportsAutoCompactContext).toBe(true);
|
||||
expect(result.current.supportsCompactionControls).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend("hello");
|
||||
});
|
||||
|
||||
expect(mockCompactConversation).toHaveBeenCalledOnce();
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined);
|
||||
});
|
||||
|
||||
it("compacts the queued persona session before sending", async () => {
|
||||
mockTokenState = {
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 8_500,
|
||||
contextLimit: 10_000,
|
||||
};
|
||||
useChatStore
|
||||
.getState()
|
||||
.replaceTokenState("session-1", mockTokenState, true);
|
||||
useChatSessionStore.getState().updateSession("session-1", {
|
||||
providerId: "goose",
|
||||
personaId: "persona-b",
|
||||
});
|
||||
|
||||
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
|
||||
|
||||
expect(capturedQueuedSend).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await capturedQueuedSend?.("hello", { id: "persona-a" });
|
||||
});
|
||||
|
||||
expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" });
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
{ id: "persona-a" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-compacts queued messages for goose personas even after switching away", async () => {
|
||||
mockSelectedAgentId = "claude-acp";
|
||||
mockTokenState = {
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 8_500,
|
||||
contextLimit: 10_000,
|
||||
};
|
||||
useChatStore
|
||||
.getState()
|
||||
.replaceTokenState("session-1", mockTokenState, true);
|
||||
useAgentStore.setState({
|
||||
personas: [
|
||||
{
|
||||
id: "persona-a",
|
||||
displayName: "Persona A",
|
||||
systemPrompt: "",
|
||||
provider: "openai",
|
||||
isBuiltin: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
|
||||
|
||||
await act(async () => {
|
||||
await capturedQueuedSend?.("hello", { id: "persona-a" });
|
||||
});
|
||||
|
||||
expect(mockCompactConversation).toHaveBeenCalledWith({ id: "persona-a" });
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
{ id: "persona-a" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips auto-compaction for queued messages targeting unsupported personas", async () => {
|
||||
mockSelectedAgentId = "goose";
|
||||
mockTokenState = {
|
||||
...INITIAL_TOKEN_STATE,
|
||||
accumulatedTotal: 8_500,
|
||||
contextLimit: 10_000,
|
||||
};
|
||||
useChatStore
|
||||
.getState()
|
||||
.replaceTokenState("session-1", mockTokenState, true);
|
||||
useAgentStore.setState({
|
||||
personas: [
|
||||
{
|
||||
id: "persona-a",
|
||||
displayName: "Persona A",
|
||||
systemPrompt: "",
|
||||
provider: "claude-acp",
|
||||
isBuiltin: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() => useChatSessionController({ sessionId: "session-1" }));
|
||||
|
||||
await act(async () => {
|
||||
await capturedQueuedSend?.("hello", { id: "persona-a" });
|
||||
});
|
||||
|
||||
expect(mockCompactConversation).not.toHaveBeenCalled();
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
{ id: "persona-a" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export function useAgentModelPickerState({
|
|||
provider: selectedModel?.provider,
|
||||
providerId: selectedModel?.providerId,
|
||||
providerName: selectedModel?.providerName,
|
||||
contextLimit: selectedModel?.contextLimit,
|
||||
recommended: selectedModel?.recommended,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
125
ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts
Normal file
125
ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import {
|
||||
AUTO_COMPACT_PREFERENCES_EVENT,
|
||||
AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
normalizeAutoCompactThreshold,
|
||||
} from "../lib/autoCompact";
|
||||
|
||||
const AUTO_COMPACT_RETRY_DELAY_MS = 1000;
|
||||
|
||||
type ConfigReadResult =
|
||||
| {
|
||||
ok: true;
|
||||
value: unknown;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
};
|
||||
|
||||
async function readConfigValue(key: string): Promise<ConfigReadResult> {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseConfigRead({ key });
|
||||
return {
|
||||
ok: true,
|
||||
value: response.value ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigValue(key: string, value: number): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.goose.GooseConfigUpsert({ key, value });
|
||||
}
|
||||
|
||||
export function useAutoCompactPreferences() {
|
||||
const [autoCompactThreshold, setAutoCompactThresholdState] = useState(
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [syncVersion, setSyncVersion] = useState(0);
|
||||
|
||||
const requestSyncFromConfig = useCallback(() => {
|
||||
setSyncVersion((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
requestSyncFromConfig();
|
||||
};
|
||||
window.addEventListener(
|
||||
AUTO_COMPACT_PREFERENCES_EVENT,
|
||||
handler as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
AUTO_COMPACT_PREFERENCES_EVENT,
|
||||
handler as EventListener,
|
||||
);
|
||||
};
|
||||
}, [requestSyncFromConfig]);
|
||||
|
||||
const syncFromConfig = useCallback(async (_syncVersion: number) => {
|
||||
void _syncVersion;
|
||||
const result = await readConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let retryTimer: number | null = null;
|
||||
|
||||
const applyConfig = async () => {
|
||||
const result = await syncFromConfig(syncVersion);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
setAutoCompactThresholdState(
|
||||
normalizeAutoCompactThreshold(result.value),
|
||||
);
|
||||
} else {
|
||||
retryTimer = window.setTimeout(
|
||||
requestSyncFromConfig,
|
||||
AUTO_COMPACT_RETRY_DELAY_MS,
|
||||
);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
};
|
||||
|
||||
void applyConfig();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer !== null) {
|
||||
window.clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [requestSyncFromConfig, syncFromConfig, syncVersion]);
|
||||
|
||||
const dispatchPreferencesEvent = useCallback(() => {
|
||||
window.dispatchEvent(new Event(AUTO_COMPACT_PREFERENCES_EVENT));
|
||||
}, []);
|
||||
|
||||
const setAutoCompactThreshold = useCallback(
|
||||
async (value: number) => {
|
||||
const normalized = normalizeAutoCompactThreshold(value);
|
||||
await writeConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY, normalized);
|
||||
setAutoCompactThresholdState(normalized);
|
||||
setIsHydrated(true);
|
||||
dispatchPreferencesEvent();
|
||||
},
|
||||
[dispatchPreferencesEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
autoCompactThreshold,
|
||||
isHydrated,
|
||||
setAutoCompactThreshold,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,10 +4,8 @@ import { useChatSessionStore } from "../stores/chatSessionStore";
|
|||
import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer";
|
||||
import {
|
||||
type ChatAttachmentDraft,
|
||||
type Message,
|
||||
createSystemNotificationMessage,
|
||||
createUserMessage,
|
||||
getTextContent,
|
||||
} from "@/shared/types/messages";
|
||||
import type { ChatState, TokenState } from "@/shared/types/chat";
|
||||
import {
|
||||
|
|
@ -28,25 +26,18 @@ import {
|
|||
buildAttachmentPromptPreamble,
|
||||
buildMessageAttachments,
|
||||
} from "../lib/attachments";
|
||||
import { sanitizeReplayMessages } from "../lib/replaySanitizer";
|
||||
import { i18n } from "@/shared/i18n";
|
||||
|
||||
// TODO: Remove this fallback once goose2 has first-class /-commands.
|
||||
const MANUAL_COMPACT_TRIGGER = "/compact";
|
||||
type CompactConversationResult = "completed" | "failed" | "skipped";
|
||||
|
||||
function isManualCompactCommandMessage(message: Message): boolean {
|
||||
if (message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedText = getTextContent(message).replace(/\s+/g, "");
|
||||
if (!normalizedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0;
|
||||
}
|
||||
|
||||
function removeManualCompactCommandMessages(messages: Message[]): Message[] {
|
||||
return messages.filter((message) => !isManualCompactCommandMessage(message));
|
||||
function createCompactionConfirmationMessage() {
|
||||
return createSystemNotificationMessage(
|
||||
i18n.t("chat:notifications.compactionComplete"),
|
||||
"compaction",
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
|
|
@ -109,7 +100,7 @@ export function useChat(
|
|||
personaInfo?: { id: string; name: string },
|
||||
options?: {
|
||||
onMessageAccepted?: (sessionId: string) => void;
|
||||
ensurePrepared?: () => Promise<void>;
|
||||
ensurePrepared?: (personaId?: string) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const store = useChatStore();
|
||||
|
|
@ -244,7 +235,7 @@ export function useChat(
|
|||
streamingPersonaIdRef.current = effectivePersonaInfo?.id ?? null;
|
||||
|
||||
try {
|
||||
await options?.ensurePrepared?.();
|
||||
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
|
||||
|
||||
store.setChatState(sessionId, "streaming");
|
||||
// When images are present with no text, pass a single space so the ACP
|
||||
|
|
@ -382,77 +373,102 @@ export function useChat(
|
|||
[sessionId],
|
||||
);
|
||||
|
||||
const compactConversation = useCallback(async () => {
|
||||
const currentChatState = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime(sessionId).chatState;
|
||||
if (currentChatState !== "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectivePersonaInfo = resolvePersonaInfo();
|
||||
const gooseSessionId = getGooseSessionId(
|
||||
sessionId,
|
||||
effectivePersonaInfo?.id,
|
||||
);
|
||||
|
||||
if (!gooseSessionId) {
|
||||
const errorMessage =
|
||||
"Session not prepared. Send a message before compacting.";
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
store.setActiveSession(sessionId);
|
||||
store.setChatState(sessionId, "compacting");
|
||||
store.setStreamingMessageId(sessionId, null);
|
||||
store.setError(sessionId, null);
|
||||
store.setSessionLoading(sessionId, true);
|
||||
clearReplayBuffer(sessionId);
|
||||
|
||||
try {
|
||||
const sendOptions = effectivePersonaInfo?.id
|
||||
? { personaId: effectivePersonaInfo.id }
|
||||
: undefined;
|
||||
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
|
||||
|
||||
// Command responses are streamed via prompt notifications, but the ACP
|
||||
// layer does not currently forward history replacement events. Drop those
|
||||
// transient chunks and refresh the session from replay instead.
|
||||
clearReplayBuffer(sessionId);
|
||||
const workingDir = getWorkingDir();
|
||||
await acpLoadSession(sessionId, gooseSessionId, workingDir);
|
||||
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const buffer = getAndDeleteReplayBuffer(sessionId);
|
||||
if (buffer) {
|
||||
store.setMessages(
|
||||
sessionId,
|
||||
removeManualCompactCommandMessages(buffer),
|
||||
);
|
||||
const compactConversation = useCallback(
|
||||
async (overridePersona?: { id: string; name?: string }) => {
|
||||
const currentChatState = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime(sessionId).chatState;
|
||||
if (currentChatState !== "idle") {
|
||||
return "skipped" as CompactConversationResult;
|
||||
}
|
||||
} catch (err) {
|
||||
clearReplayBuffer(sessionId);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const errorMessage = getErrorMessage(err);
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
const effectivePersonaInfo = resolvePersonaInfo(
|
||||
overridePersona?.id,
|
||||
overridePersona?.name,
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
} finally {
|
||||
store.setChatState(sessionId, "idle");
|
||||
let gooseSessionId = getGooseSessionId(
|
||||
sessionId,
|
||||
effectivePersonaInfo?.id,
|
||||
);
|
||||
|
||||
if (!gooseSessionId) {
|
||||
try {
|
||||
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
return "failed" as CompactConversationResult;
|
||||
}
|
||||
gooseSessionId = getGooseSessionId(sessionId, effectivePersonaInfo?.id);
|
||||
}
|
||||
|
||||
if (!gooseSessionId) {
|
||||
const errorMessage =
|
||||
"Session not prepared. Send a message before compacting.";
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
return "failed" as CompactConversationResult;
|
||||
}
|
||||
|
||||
store.setActiveSession(sessionId);
|
||||
store.setChatState(sessionId, "compacting");
|
||||
store.setStreamingMessageId(sessionId, null);
|
||||
store.setPendingAssistantProvider(sessionId, null);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
}
|
||||
}, [getWorkingDir, resolvePersonaInfo, sessionId, store]);
|
||||
store.setError(sessionId, null);
|
||||
store.setSessionLoading(sessionId, true);
|
||||
clearReplayBuffer(sessionId);
|
||||
|
||||
try {
|
||||
const sendOptions = effectivePersonaInfo?.id
|
||||
? { personaId: effectivePersonaInfo.id }
|
||||
: undefined;
|
||||
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
|
||||
|
||||
// Command responses are streamed via prompt notifications, but the ACP
|
||||
// layer does not currently forward history replacement events. Drop those
|
||||
// transient chunks and refresh the session from replay instead.
|
||||
clearReplayBuffer(sessionId);
|
||||
const workingDir = getWorkingDir();
|
||||
await acpLoadSession(sessionId, gooseSessionId, workingDir);
|
||||
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const buffer = getAndDeleteReplayBuffer(sessionId);
|
||||
if (buffer) {
|
||||
store.setMessages(sessionId, [
|
||||
...sanitizeReplayMessages(buffer),
|
||||
createCompactionConfirmationMessage(),
|
||||
]);
|
||||
} else {
|
||||
store.addMessage(sessionId, createCompactionConfirmationMessage());
|
||||
}
|
||||
return "completed" as CompactConversationResult;
|
||||
} catch (err) {
|
||||
clearReplayBuffer(sessionId);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const errorMessage = getErrorMessage(err);
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
return "failed" as CompactConversationResult;
|
||||
} finally {
|
||||
store.setChatState(sessionId, "idle");
|
||||
store.setStreamingMessageId(sessionId, null);
|
||||
store.setPendingAssistantProvider(sessionId, null);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
}
|
||||
},
|
||||
[getWorkingDir, options, resolvePersonaInfo, sessionId, store],
|
||||
);
|
||||
|
||||
const stopStreaming = stopGeneration;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChatAttachmentDraft } from "@/shared/types/messages";
|
||||
import { INITIAL_TOKEN_STATE } from "@/shared/types/chat";
|
||||
import { useChat } from "./useChat";
|
||||
import { useAutoCompactPreferences } from "./useAutoCompactPreferences";
|
||||
import { useMessageQueue } from "./useMessageQueue";
|
||||
import { useChatStore } from "../stores/chatStore";
|
||||
import { useChatSessionStore } from "../stores/chatSessionStore";
|
||||
|
|
@ -15,6 +17,11 @@ import {
|
|||
resolveProjectDefaultArtifactRoot,
|
||||
} from "@/features/projects/lib/chatProjectContext";
|
||||
import { setStoredModelPreference } from "../lib/modelPreferences";
|
||||
import {
|
||||
shouldAutoCompactContext,
|
||||
supportsContextAutoCompaction,
|
||||
supportsContextCompactionControls,
|
||||
} from "../lib/autoCompact";
|
||||
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
|
||||
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
|
||||
import {
|
||||
|
|
@ -77,6 +84,11 @@ export function useChatSessionController({
|
|||
: undefined,
|
||||
);
|
||||
const project = storedProject ?? null;
|
||||
const { autoCompactThreshold, isHydrated: isAutoCompactThresholdHydrated } =
|
||||
useAutoCompactPreferences();
|
||||
const hasContextUsageSnapshot = useChatStore(
|
||||
(s) => s.sessionStateById[stateSessionId]?.hasUsageSnapshot ?? false,
|
||||
);
|
||||
const selectedProvider =
|
||||
pendingProviderId ??
|
||||
session?.providerId ??
|
||||
|
|
@ -253,6 +265,29 @@ export function useChatSessionController({
|
|||
sessionId,
|
||||
]);
|
||||
|
||||
const handleProviderChangeWithContextReset = useCallback(
|
||||
(providerId: string) => {
|
||||
if (providerId === selectedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
useChatStore.getState().resetTokenState(stateSessionId);
|
||||
handleProviderChange(providerId);
|
||||
},
|
||||
[handleProviderChange, selectedProvider, stateSessionId],
|
||||
);
|
||||
|
||||
const handleModelChangeWithContextReset = useCallback(
|
||||
(modelId: string) => {
|
||||
if (modelId === effectiveModelSelection?.id) {
|
||||
return;
|
||||
}
|
||||
useChatStore.getState().resetTokenState(stateSessionId);
|
||||
handleModelChange(modelId);
|
||||
},
|
||||
[effectiveModelSelection?.id, handleModelChange, stateSessionId],
|
||||
);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(projectId: string | null) => {
|
||||
if (!sessionId) {
|
||||
|
|
@ -364,6 +399,7 @@ export function useChatSessionController({
|
|||
chatState,
|
||||
tokenState,
|
||||
sendMessage,
|
||||
compactConversation,
|
||||
stopStreaming,
|
||||
streamingMessageId,
|
||||
} = useChat(
|
||||
|
|
@ -374,11 +410,92 @@ export function useChatSessionController({
|
|||
{
|
||||
onMessageAccepted: sessionId ? onMessageAccepted : undefined,
|
||||
ensurePrepared: selectedProvider
|
||||
? () =>
|
||||
prepareSelectedProvider(selectedProvider, effectiveModelSelection)
|
||||
? (personaId?: string) =>
|
||||
prepareCurrentSession(
|
||||
selectedProvider,
|
||||
project,
|
||||
activeWorkspace?.path,
|
||||
personaId,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
const resolvedTokenState = tokenState ?? INITIAL_TOKEN_STATE;
|
||||
const supportsAutoCompactContext =
|
||||
supportsContextAutoCompaction(selectedAgentId);
|
||||
const supportsCompactionControls =
|
||||
supportsContextCompactionControls(selectedAgentId);
|
||||
const isCompactingContext = chatState === "compacting";
|
||||
const resolveAutoCompactAgentId = useCallback(
|
||||
(overridePersona?: { id: string; name?: string }) => {
|
||||
if (!overridePersona?.id) {
|
||||
return selectedAgentId;
|
||||
}
|
||||
|
||||
const targetPersona = personas.find(
|
||||
(persona) => persona.id === overridePersona.id,
|
||||
);
|
||||
if (!targetPersona?.provider) {
|
||||
return selectedAgentId;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveAgentProviderCatalogIdStrict(targetPersona.provider) ?? "goose"
|
||||
);
|
||||
},
|
||||
[personas, selectedAgentId],
|
||||
);
|
||||
const canAutoCompactBeforeSend = useCallback(
|
||||
(overridePersona?: { id: string; name?: string }) => {
|
||||
const targetAgentId = resolveAutoCompactAgentId(overridePersona);
|
||||
if (
|
||||
!sessionId ||
|
||||
!supportsContextAutoCompaction(targetAgentId) ||
|
||||
!isAutoCompactThresholdHydrated
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const liveRuntime = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime(stateSessionId);
|
||||
return shouldAutoCompactContext(
|
||||
liveRuntime.tokenState.accumulatedTotal,
|
||||
liveRuntime.tokenState.contextLimit,
|
||||
autoCompactThreshold,
|
||||
);
|
||||
},
|
||||
[
|
||||
autoCompactThreshold,
|
||||
isAutoCompactThresholdHydrated,
|
||||
resolveAutoCompactAgentId,
|
||||
sessionId,
|
||||
stateSessionId,
|
||||
],
|
||||
);
|
||||
const sendWithAutoCompact = useCallback(
|
||||
(
|
||||
text: string,
|
||||
overridePersona?: { id: string; name?: string },
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => {
|
||||
if (!canAutoCompactBeforeSend(overridePersona)) {
|
||||
void sendMessage(text, overridePersona, attachments);
|
||||
return true;
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
const compactionResult = await compactConversation(overridePersona);
|
||||
if (compactionResult !== "completed") {
|
||||
return false;
|
||||
}
|
||||
|
||||
void sendMessage(text, overridePersona, attachments);
|
||||
return true;
|
||||
})();
|
||||
},
|
||||
[canAutoCompactBeforeSend, compactConversation, sendMessage],
|
||||
);
|
||||
const isLoadingHistory = useChatStore((s) =>
|
||||
sessionId
|
||||
? s.loadingSessionIds.has(sessionId) &&
|
||||
|
|
@ -388,11 +505,12 @@ export function useChatSessionController({
|
|||
const deferredSend = useRef<{
|
||||
text: string;
|
||||
attachments?: ChatAttachmentDraft[];
|
||||
resolve?: (accepted: boolean) => void;
|
||||
} | null>(null);
|
||||
const queue = useMessageQueue(
|
||||
stateSessionId,
|
||||
sessionId ? chatState : "thinking",
|
||||
sendMessage,
|
||||
sendWithAutoCompact,
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
|
|
@ -401,21 +519,22 @@ export function useChatSessionController({
|
|||
if (!queue.queuedMessage) {
|
||||
queue.enqueue(text, personaId, attachments);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (personaId && personaId !== selectedPersonaId) {
|
||||
handlePersonaChange(personaId);
|
||||
deferredSend.current = { text, attachments };
|
||||
return;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
deferredSend.current = { text, attachments, resolve };
|
||||
});
|
||||
}
|
||||
|
||||
if (chatState !== "idle" && !queue.queuedMessage) {
|
||||
queue.enqueue(text, personaId, attachments);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
sendMessage(text, undefined, attachments);
|
||||
return sendWithAutoCompact(text, undefined, attachments);
|
||||
},
|
||||
[
|
||||
chatState,
|
||||
|
|
@ -423,17 +542,27 @@ export function useChatSessionController({
|
|||
queue,
|
||||
sessionId,
|
||||
selectedPersonaId,
|
||||
sendMessage,
|
||||
sendWithAutoCompact,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (deferredSend.current && selectedPersona) {
|
||||
const { text, attachments } = deferredSend.current;
|
||||
const { text, attachments, resolve } = deferredSend.current;
|
||||
deferredSend.current = null;
|
||||
sendMessage(text, undefined, attachments);
|
||||
const sendResult = sendWithAutoCompact(text, undefined, attachments);
|
||||
if (sendResult instanceof Promise) {
|
||||
void sendResult.then((accepted) => {
|
||||
if (accepted === false) {
|
||||
useChatStore.getState().setDraft(stateSessionId, text);
|
||||
}
|
||||
resolve?.(accepted !== false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve?.(true);
|
||||
}
|
||||
}, [selectedPersona, sendMessage]);
|
||||
}, [selectedPersona, sendWithAutoCompact, stateSessionId]);
|
||||
|
||||
const handleCreatePersona = useCallback(() => {
|
||||
if (onCreatePersonaRequested) {
|
||||
|
|
@ -605,9 +734,17 @@ export function useChatSessionController({
|
|||
allowedArtifactRoots,
|
||||
messages,
|
||||
chatState,
|
||||
tokenState,
|
||||
tokenState: resolvedTokenState,
|
||||
stopStreaming,
|
||||
streamingMessageId,
|
||||
compactConversation,
|
||||
canCompactContext:
|
||||
supportsCompactionControls && messages.length > 0 && chatState === "idle",
|
||||
isCompactingContext,
|
||||
supportsAutoCompactContext,
|
||||
supportsCompactionControls,
|
||||
isContextUsageReady:
|
||||
hasContextUsageSnapshot && resolvedTokenState.contextLimit > 0,
|
||||
isLoadingHistory,
|
||||
queue,
|
||||
handleSend,
|
||||
|
|
@ -623,13 +760,13 @@ export function useChatSessionController({
|
|||
pickerAgents,
|
||||
providersLoading,
|
||||
selectedProvider: selectedAgentId,
|
||||
handleProviderChange,
|
||||
handleProviderChange: handleProviderChangeWithContextReset,
|
||||
currentModelId: effectiveModelSelection?.id ?? null,
|
||||
currentModelName: effectiveModelSelection?.name ?? null,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
modelStatusMessage,
|
||||
handleModelChange,
|
||||
handleModelChange: handleModelChangeWithContextReset,
|
||||
selectedProjectId: effectiveProjectId,
|
||||
availableProjects,
|
||||
handleProjectChange,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,35 @@
|
|||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import type { ChatState } from "@/shared/types/chat";
|
||||
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
|
||||
import type { ChatAttachmentDraft } from "@/shared/types/messages";
|
||||
import { useChatStore } from "../stores/chatStore";
|
||||
|
||||
const MAX_CONSECUTIVE_SEND_FAILURES = 2;
|
||||
|
||||
function getQueuedMessageKey(
|
||||
queuedMessage: {
|
||||
text: string;
|
||||
personaId?: string;
|
||||
attachments?: ChatAttachmentDraft[];
|
||||
} | null,
|
||||
): string | null {
|
||||
if (!queuedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
text: queuedMessage.text,
|
||||
personaId: queuedMessage.personaId ?? null,
|
||||
attachments:
|
||||
queuedMessage.attachments?.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
kind: attachment.kind,
|
||||
name: attachment.name,
|
||||
path: "path" in attachment ? (attachment.path ?? null) : null,
|
||||
})) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-slot message queue that holds one pending message while the agent is
|
||||
* busy and auto-sends it when the chat transitions back to idle.
|
||||
|
|
@ -18,19 +45,104 @@ export function useMessageQueue(
|
|||
text: string,
|
||||
overridePersona?: { id: string; name?: string },
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => void,
|
||||
) => boolean | Promise<boolean>,
|
||||
) {
|
||||
const queuedMessage = useChatStore(
|
||||
(s) => s.queuedMessageBySession[sessionId] ?? null,
|
||||
);
|
||||
const previousChatStateRef = useRef(chatState);
|
||||
const idleCycleRef = useRef(0);
|
||||
const lastAttemptRef = useRef<{
|
||||
key: string;
|
||||
idleCycle: number;
|
||||
} | null>(null);
|
||||
const failureStateRef = useRef<{
|
||||
key: string;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const queuedMessageKey = useMemo(
|
||||
() => getQueuedMessageKey(queuedMessage),
|
||||
[queuedMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatState === "idle" && queuedMessage) {
|
||||
const { text, personaId, attachments } = queuedMessage;
|
||||
useChatStore.getState().dismissQueuedMessage(sessionId);
|
||||
sendMessage(text, personaId ? { id: personaId } : undefined, attachments);
|
||||
if (queuedMessageKey !== lastAttemptRef.current?.key) {
|
||||
lastAttemptRef.current = null;
|
||||
}
|
||||
}, [chatState, queuedMessage, sendMessage, sessionId]);
|
||||
if (queuedMessageKey !== failureStateRef.current?.key) {
|
||||
failureStateRef.current = null;
|
||||
}
|
||||
}, [queuedMessageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatState === "idle" && previousChatStateRef.current !== "idle") {
|
||||
idleCycleRef.current += 1;
|
||||
}
|
||||
previousChatStateRef.current = chatState;
|
||||
}, [chatState]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasReachedRetryLimit =
|
||||
failureStateRef.current?.key === queuedMessageKey &&
|
||||
failureStateRef.current.count >= MAX_CONSECUTIVE_SEND_FAILURES;
|
||||
const alreadyAttemptedThisIdleCycle =
|
||||
lastAttemptRef.current?.key === queuedMessageKey &&
|
||||
lastAttemptRef.current.idleCycle === idleCycleRef.current;
|
||||
|
||||
if (
|
||||
chatState !== "idle" ||
|
||||
!queuedMessage ||
|
||||
!queuedMessageKey ||
|
||||
hasReachedRetryLimit ||
|
||||
alreadyAttemptedThisIdleCycle
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAttemptRef.current = {
|
||||
key: queuedMessageKey,
|
||||
idleCycle: idleCycleRef.current,
|
||||
};
|
||||
|
||||
const { text, personaId, attachments } = queuedMessage;
|
||||
const sendResult = sendMessage(
|
||||
text,
|
||||
personaId ? { id: personaId } : undefined,
|
||||
attachments,
|
||||
);
|
||||
|
||||
const finalize = (accepted: boolean | undefined) => {
|
||||
const latestQueuedMessage =
|
||||
useChatStore.getState().queuedMessageBySession[sessionId] ?? null;
|
||||
if (getQueuedMessageKey(latestQueuedMessage) !== queuedMessageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accepted === false) {
|
||||
const previousFailureCount =
|
||||
failureStateRef.current?.key === queuedMessageKey
|
||||
? failureStateRef.current.count
|
||||
: 0;
|
||||
failureStateRef.current = {
|
||||
key: queuedMessageKey,
|
||||
count: previousFailureCount + 1,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
failureStateRef.current = null;
|
||||
lastAttemptRef.current = null;
|
||||
useChatStore.getState().dismissQueuedMessage(sessionId);
|
||||
};
|
||||
|
||||
if (isPromiseLike<boolean>(sendResult)) {
|
||||
void sendResult
|
||||
.then((accepted) => finalize(accepted))
|
||||
.catch(() => finalize(false));
|
||||
} else {
|
||||
finalize(sendResult);
|
||||
}
|
||||
}, [chatState, queuedMessage, queuedMessageKey, sendMessage, sessionId]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDictationConfig } from "@/shared/api/dictation";
|
||||
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
|
||||
import type { DictationProviderStatus } from "@/shared/types/dictation";
|
||||
import type { ChatAttachmentDraft } from "@/shared/types/messages";
|
||||
import { useDictationRecorder } from "./useDictationRecorder";
|
||||
|
|
@ -21,7 +22,7 @@ interface UseVoiceDictationOptions {
|
|||
text: string,
|
||||
personaId?: string,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => void;
|
||||
) => boolean | Promise<boolean>;
|
||||
resetTextarea: () => void;
|
||||
/**
|
||||
* When true, auto-submit on trigger phrase will NOT call `onSend`.
|
||||
|
|
@ -139,11 +140,35 @@ export function useVoiceDictation({
|
|||
textRef.current = merged;
|
||||
return;
|
||||
}
|
||||
onSend(
|
||||
const sendResult = onSend(
|
||||
merged.trim(),
|
||||
selectedPersonaId ?? undefined,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
);
|
||||
if (isPromiseLike<boolean>(sendResult)) {
|
||||
void sendResult
|
||||
.then((accepted) => {
|
||||
if (accepted === false) {
|
||||
setText(merged);
|
||||
textRef.current = merged;
|
||||
return;
|
||||
}
|
||||
setText("");
|
||||
textRef.current = "";
|
||||
clearAttachments();
|
||||
resetTextarea();
|
||||
})
|
||||
.catch(() => {
|
||||
setText(merged);
|
||||
textRef.current = merged;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sendResult === false) {
|
||||
setText(merged);
|
||||
textRef.current = merged;
|
||||
return;
|
||||
}
|
||||
setText("");
|
||||
textRef.current = "";
|
||||
clearAttachments();
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
96
ui/goose2/src/features/chat/lib/autoCompact.ts
Normal file
96
ui/goose2/src/features/chat/lib/autoCompact.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export const AUTO_COMPACT_THRESHOLD_CONFIG_KEY = "GOOSE_AUTO_COMPACT_THRESHOLD";
|
||||
export const AUTO_COMPACT_PREFERENCES_EVENT = "goose:auto-compact-preferences";
|
||||
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 0.8;
|
||||
export const MIN_AUTO_COMPACT_THRESHOLD_PERCENT = 1;
|
||||
export const MAX_AUTO_COMPACT_THRESHOLD_PERCENT = 100;
|
||||
const CONTEXT_COMPACTION_PROVIDER_IDS = new Set(["goose"]);
|
||||
|
||||
function coerceAutoCompactThreshold(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeAutoCompactThreshold(value: unknown): number {
|
||||
const parsed = coerceAutoCompactThreshold(value);
|
||||
if (parsed === null) {
|
||||
return DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
}
|
||||
|
||||
if (parsed <= 0 || parsed >= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function clampAutoCompactThresholdPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return Math.round(DEFAULT_AUTO_COMPACT_THRESHOLD * 100);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
MIN_AUTO_COMPACT_THRESHOLD_PERCENT,
|
||||
Math.min(MAX_AUTO_COMPACT_THRESHOLD_PERCENT, Math.round(value)),
|
||||
);
|
||||
}
|
||||
|
||||
export function autoCompactThresholdToPercent(value: unknown): number {
|
||||
const parsed = coerceAutoCompactThreshold(value);
|
||||
if (parsed === null) {
|
||||
return Math.round(DEFAULT_AUTO_COMPACT_THRESHOLD * 100);
|
||||
}
|
||||
|
||||
if (parsed <= 0 || parsed >= 1) {
|
||||
return MAX_AUTO_COMPACT_THRESHOLD_PERCENT;
|
||||
}
|
||||
|
||||
return clampAutoCompactThresholdPercent(parsed * 100);
|
||||
}
|
||||
|
||||
export function autoCompactPercentToThreshold(value: number): number {
|
||||
return clampAutoCompactThresholdPercent(value) / 100;
|
||||
}
|
||||
|
||||
export function shouldAutoCompactContext(
|
||||
usedTokens: number,
|
||||
contextLimit: number,
|
||||
threshold: number,
|
||||
): boolean {
|
||||
if (usedTokens <= 0 || contextLimit <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (threshold <= 0 || threshold >= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return usedTokens / contextLimit > threshold;
|
||||
}
|
||||
|
||||
function supportsContextCompactionProvider(
|
||||
providerId: string | null | undefined,
|
||||
): boolean {
|
||||
return providerId != null && CONTEXT_COMPACTION_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
export function supportsContextAutoCompaction(
|
||||
providerId: string | null | undefined,
|
||||
): boolean {
|
||||
return supportsContextCompactionProvider(providerId);
|
||||
}
|
||||
|
||||
export function supportsContextCompactionControls(
|
||||
providerId: string | null | undefined,
|
||||
): boolean {
|
||||
return supportsContextCompactionProvider(providerId);
|
||||
}
|
||||
31
ui/goose2/src/features/chat/lib/replaySanitizer.ts
Normal file
31
ui/goose2/src/features/chat/lib/replaySanitizer.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Message } from "@/shared/types/messages";
|
||||
import { getTextContent } from "@/shared/types/messages";
|
||||
|
||||
const MANUAL_COMPACT_TRIGGER = "/compact";
|
||||
const ALTERNATE_COMPACT_TRIGGERS = new Set(["/summarize"]);
|
||||
|
||||
export function isManualCompactReplayArtifact(message: Message): boolean {
|
||||
if (message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawText = getTextContent(message).trim();
|
||||
if (!rawText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedText = rawText.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (ALTERNATE_COMPACT_TRIGGERS.has(normalizedText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const collapsedText = normalizedText.replace(/\s+/g, "");
|
||||
return (
|
||||
collapsedText.length > 0 &&
|
||||
collapsedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeReplayMessages(messages: Message[]): Message[] {
|
||||
return messages.filter((message) => !isManualCompactReplayArtifact(message));
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ interface ChatStoreActions {
|
|||
markSessionRead: (sessionId: string) => void;
|
||||
markSessionUnread: (sessionId: string) => void;
|
||||
updateTokenState: (sessionId: string, state: Partial<TokenState>) => void;
|
||||
replaceTokenState: (
|
||||
sessionId: string,
|
||||
tokenState: TokenState,
|
||||
hasUsageSnapshot?: boolean,
|
||||
) => void;
|
||||
resetTokenState: (sessionId: string) => void;
|
||||
enqueueMessage: (sessionId: string, message: QueuedMessage) => void;
|
||||
dismissQueuedMessage: (sessionId: string) => void;
|
||||
|
|
@ -382,11 +387,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||
accumulatedTotal,
|
||||
contextLimit: partial.contextLimit ?? current.contextLimit,
|
||||
},
|
||||
hasUsageSnapshot: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
replaceTokenState: (sessionId, tokenState, hasUsageSnapshot = true) =>
|
||||
set((state) => ({
|
||||
sessionStateById: {
|
||||
...state.sessionStateById,
|
||||
[sessionId]: {
|
||||
...(state.sessionStateById[sessionId] ??
|
||||
createInitialSessionRuntime()),
|
||||
tokenState: { ...tokenState },
|
||||
hasUsageSnapshot,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
resetTokenState: (sessionId) =>
|
||||
set((state) => ({
|
||||
sessionStateById: {
|
||||
|
|
@ -395,6 +414,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||
...(state.sessionStateById[sessionId] ??
|
||||
createInitialSessionRuntime()),
|
||||
tokenState: { ...INITIAL_TOKEN_STATE },
|
||||
hasUsageSnapshot: false,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ModelOption {
|
|||
provider?: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
contextLimit?: number | null;
|
||||
/** Whether this model should appear in the compact recommended picker. */
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
|
@ -25,7 +26,7 @@ export interface ChatInputProps {
|
|||
text: string,
|
||||
personaId?: string,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => void;
|
||||
) => boolean | Promise<boolean>;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
|
|
@ -56,7 +57,9 @@ export interface ChatInputProps {
|
|||
}) => void;
|
||||
contextTokens?: number;
|
||||
contextLimit?: number;
|
||||
onCompactContext?: () => void | Promise<void>;
|
||||
isContextUsageReady?: boolean;
|
||||
onCompactContext?: () => Promise<unknown> | undefined;
|
||||
canCompactContext?: boolean;
|
||||
isCompactingContext?: boolean;
|
||||
supportsCompactionControls?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
|||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { isPromiseLike } from "@/shared/lib/isPromiseLike";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverAnchor } from "@/shared/ui/popover";
|
||||
|
|
@ -19,8 +20,19 @@ import {
|
|||
} from "../hooks/useChatInputAttachments";
|
||||
import { ChatInputAttachments } from "./ChatInputAttachments";
|
||||
import { useVoiceDictation } from "../hooks/useVoiceDictation";
|
||||
import type { ChatAttachmentDraft } from "@/shared/types/messages";
|
||||
import type { ChatInputProps } from "../types";
|
||||
|
||||
function attachmentSnapshotsMatch(
|
||||
current: ChatAttachmentDraft[],
|
||||
snapshot: ChatAttachmentDraft[],
|
||||
) {
|
||||
return (
|
||||
current.length === snapshot.length &&
|
||||
current.every((attachment, index) => attachment.id === snapshot[index]?.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
|
|
@ -51,17 +63,22 @@ export function ChatInput({
|
|||
onCreateProject,
|
||||
contextTokens = 0,
|
||||
contextLimit = 0,
|
||||
isContextUsageReady,
|
||||
onCompactContext,
|
||||
canCompactContext = false,
|
||||
isCompactingContext = false,
|
||||
supportsCompactionControls,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const [text, setTextRaw] = useState(initialValue);
|
||||
const textRef = useRef(initialValue);
|
||||
useEffect(() => {
|
||||
setTextRaw(initialValue);
|
||||
textRef.current = initialValue;
|
||||
}, [initialValue]);
|
||||
const setText = useCallback(
|
||||
(value: string) => {
|
||||
textRef.current = value;
|
||||
setTextRaw(value);
|
||||
onDraftChange?.(value);
|
||||
},
|
||||
|
|
@ -77,6 +94,8 @@ export function ChatInput({
|
|||
removeAttachment,
|
||||
clearAttachments,
|
||||
} = useChatInputAttachments();
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
|
||||
const resetTextarea = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
|
|
@ -152,7 +171,7 @@ export function ChatInput({
|
|||
|
||||
useEffect(() => textareaRef.current?.focus(), []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!canSend) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -175,11 +194,25 @@ export function ChatInput({
|
|||
dictation.stopRecording({ flushPending: false });
|
||||
}
|
||||
|
||||
onSend(
|
||||
text.trim(),
|
||||
const submittedText = text;
|
||||
const submittedAttachments = attachments;
|
||||
const sendResult = onSend(
|
||||
submittedText.trim(),
|
||||
selectedPersonaId ?? undefined,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
submittedAttachments.length > 0 ? submittedAttachments : undefined,
|
||||
);
|
||||
const accepted = isPromiseLike<boolean>(sendResult)
|
||||
? await sendResult
|
||||
: sendResult;
|
||||
if (accepted === false) {
|
||||
return;
|
||||
}
|
||||
const draftStillMatchesSubmission =
|
||||
textRef.current === submittedText &&
|
||||
attachmentSnapshotsMatch(attachmentsRef.current, submittedAttachments);
|
||||
if (!draftStillMatchesSubmission) {
|
||||
return;
|
||||
}
|
||||
setText("");
|
||||
clearAttachments();
|
||||
if (textareaRef.current) {
|
||||
|
|
@ -219,7 +252,7 @@ export function ChatInput({
|
|||
}
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -447,9 +480,11 @@ export function ChatInput({
|
|||
onCreateProject={onCreateProject}
|
||||
contextTokens={contextTokens}
|
||||
contextLimit={contextLimit}
|
||||
isContextUsageReady={isContextUsageReady}
|
||||
onCompactContext={onCompactContext}
|
||||
canCompactContext={canCompactContext}
|
||||
isCompactingContext={isCompactingContext}
|
||||
supportsCompactionControls={supportsCompactionControls}
|
||||
canSend={canSend}
|
||||
isStreaming={isStreaming}
|
||||
hasQueuedMessage={hasQueuedMessage}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Mic,
|
||||
ArrowUp,
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Paperclip,
|
||||
File,
|
||||
FolderOpen,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
|
|
@ -31,6 +32,8 @@ import { AgentModelPicker } from "./AgentModelPicker";
|
|||
import type { ModelOption } from "../types";
|
||||
import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons";
|
||||
import { getCatalogEntry } from "@/features/providers/providerCatalog";
|
||||
import { supportsContextCompactionControls } from "../lib/autoCompact";
|
||||
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
|
||||
|
||||
const NO_PROJECT_VALUE = "__no_project__";
|
||||
const CREATE_PROJECT_VALUE = "__create_project__";
|
||||
|
|
@ -76,10 +79,12 @@ interface ChatInputToolbarProps {
|
|||
// Context
|
||||
contextTokens: number;
|
||||
contextLimit: number;
|
||||
isContextUsageReady?: boolean;
|
||||
supportsCompactionControls?: boolean;
|
||||
// Actions
|
||||
canCompactContext?: boolean;
|
||||
isCompactingContext?: boolean;
|
||||
onCompactContext?: () => void | Promise<void>;
|
||||
onCompactContext?: () => Promise<unknown> | undefined;
|
||||
canSend: boolean;
|
||||
isStreaming: boolean;
|
||||
hasQueuedMessage: boolean;
|
||||
|
|
@ -118,6 +123,8 @@ export function ChatInputToolbar({
|
|||
onCreateProject,
|
||||
contextTokens,
|
||||
contextLimit,
|
||||
isContextUsageReady,
|
||||
supportsCompactionControls,
|
||||
canCompactContext = false,
|
||||
isCompactingContext = false,
|
||||
onCompactContext,
|
||||
|
|
@ -138,6 +145,9 @@ export function ChatInputToolbar({
|
|||
const { t } = useTranslation("chat");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
|
||||
const compactionControlsSupported =
|
||||
supportsCompactionControls ??
|
||||
supportsContextCompactionControls(selectedProvider);
|
||||
|
||||
const agentProviders = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -171,6 +181,7 @@ export function ChatInputToolbar({
|
|||
: undefined;
|
||||
const contextProgress =
|
||||
contextLimit > 0 ? Math.min(contextTokens / contextLimit, 1) : 0;
|
||||
const showContextUsage = isContextUsageReady ?? contextLimit > 0;
|
||||
const contextPercentDigits =
|
||||
contextProgress > 0 && contextProgress < 0.1 ? 1 : 0;
|
||||
const usedPercentLabel = formatNumber(contextProgress, {
|
||||
|
|
@ -203,6 +214,17 @@ export function ChatInputToolbar({
|
|||
void onCompactContext();
|
||||
};
|
||||
|
||||
const handleOpenAutoCompactSettings = () => {
|
||||
setIsContextPopoverOpen(false);
|
||||
requestOpenSettings("compaction");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showContextUsage && isContextPopoverOpen) {
|
||||
setIsContextPopoverOpen(false);
|
||||
}
|
||||
}, [isContextPopoverOpen, showContextUsage]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Left side: pickers */}
|
||||
|
|
@ -286,7 +308,7 @@ export function ChatInputToolbar({
|
|||
/>
|
||||
)}
|
||||
|
||||
{contextLimit > 0 && (
|
||||
{showContextUsage && (
|
||||
<Popover
|
||||
open={isContextPopoverOpen}
|
||||
onOpenChange={setIsContextPopoverOpen}
|
||||
|
|
@ -317,7 +339,7 @@ export function ChatInputToolbar({
|
|||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-52 rounded-2xl p-1 text-left"
|
||||
className="w-60 rounded-2xl p-1 text-left"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-foreground">
|
||||
{t("toolbar.contextWindow")}
|
||||
|
|
@ -336,18 +358,33 @@ export function ChatInputToolbar({
|
|||
</div>
|
||||
<div className="shrink-0">{usedPercentLabel}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="w-full justify-center"
|
||||
onClick={handleCompactContext}
|
||||
disabled={!canCompactContext || isCompactingContext}
|
||||
>
|
||||
{isCompactingContext
|
||||
? t("toolbar.compacting")
|
||||
: t("toolbar.compactNow")}
|
||||
</Button>
|
||||
{compactionControlsSupported ? (
|
||||
<div className="flex items-center gap-1 pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="min-w-0 flex-1 justify-center"
|
||||
onClick={handleCompactContext}
|
||||
disabled={!canCompactContext || isCompactingContext}
|
||||
>
|
||||
{isCompactingContext
|
||||
? t("toolbar.compacting")
|
||||
: t("toolbar.compactNow")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 rounded-full"
|
||||
onClick={handleOpenAutoCompactSettings}
|
||||
aria-label={t("toolbar.settings")}
|
||||
title={t("toolbar.settings")}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,10 @@ export function ChatView({
|
|||
|
||||
<ChatInput
|
||||
onSend={controller.handleSend}
|
||||
disabled={controller.projectMetadataPending}
|
||||
disabled={
|
||||
controller.projectMetadataPending ||
|
||||
controller.isCompactingContext
|
||||
}
|
||||
queuedMessage={controller.queue.queuedMessage}
|
||||
onDismissQueue={controller.queue.dismiss}
|
||||
initialValue={controller.draftValue}
|
||||
|
|
@ -148,6 +151,11 @@ export function ChatView({
|
|||
}
|
||||
contextTokens={controller.tokenState.accumulatedTotal}
|
||||
contextLimit={controller.tokenState.contextLimit}
|
||||
isContextUsageReady={controller.isContextUsageReady}
|
||||
onCompactContext={controller.compactConversation}
|
||||
canCompactContext={controller.canCompactContext}
|
||||
isCompactingContext={controller.isCompactingContext}
|
||||
supportsCompactionControls={controller.supportsCompactionControls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ function renderContentBlock(
|
|||
case "systemNotification": {
|
||||
const sn = content as SystemNotificationContent;
|
||||
const isError = sn.notificationType === "error";
|
||||
const isCompaction = sn.notificationType === "compaction";
|
||||
return (
|
||||
<div
|
||||
key={`notification-${index}`}
|
||||
|
|
@ -269,10 +270,13 @@ function renderContentBlock(
|
|||
"rounded-md border p-2 text-xs",
|
||||
isError
|
||||
? "border-danger/30 bg-danger/10 text-danger"
|
||||
: "border-border bg-accent text-muted-foreground",
|
||||
: isCompaction
|
||||
? "inline-flex items-center justify-center gap-2 border-success/30 bg-success/10 font-medium text-success"
|
||||
: "border-border bg-accent text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{sn.text}
|
||||
{isCompaction ? <Check className="size-3.5 shrink-0" /> : null}
|
||||
<span>{sn.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatInput } from "../ChatInput";
|
||||
|
||||
const mockVoiceDictation = {
|
||||
isEnabled: true,
|
||||
isRecording: false,
|
||||
isTranscribing: false,
|
||||
isStarting: vi.fn(() => false),
|
||||
stopRecording: vi.fn(),
|
||||
toggleRecording: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../hooks/useVoiceDictation", () => ({
|
||||
useVoiceDictation: () => mockVoiceDictation,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
|
||||
useAgentProviderStatus: () => ({
|
||||
readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]),
|
||||
loading: false,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockListFilesForMentions = vi.fn<
|
||||
(roots: string[], maxResults?: number) => Promise<string[]>
|
||||
>(async () => []);
|
||||
|
||||
vi.mock("@/shared/api/system", () => ({
|
||||
listFilesForMentions: (roots: string[], maxResults?: number) =>
|
||||
mockListFilesForMentions(roots, maxResults),
|
||||
}));
|
||||
|
||||
describe("ChatInput async send handling", () => {
|
||||
beforeEach(() => {
|
||||
mockListFilesForMentions.mockClear();
|
||||
mockListFilesForMentions.mockResolvedValue([]);
|
||||
mockVoiceDictation.isEnabled = true;
|
||||
mockVoiceDictation.isRecording = false;
|
||||
mockVoiceDictation.isTranscribing = false;
|
||||
mockVoiceDictation.isStarting.mockReset();
|
||||
mockVoiceDictation.isStarting.mockReturnValue(false);
|
||||
mockVoiceDictation.stopRecording.mockReset();
|
||||
mockVoiceDictation.toggleRecording.mockReset();
|
||||
});
|
||||
|
||||
it("clears the composer after an accepted async send when the draft is unchanged", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "hello");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
resolveSend(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves newer draft text when an async send resolves later", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "hello");
|
||||
await user.keyboard("{Enter}");
|
||||
await user.type(input, " world");
|
||||
|
||||
resolveSend(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue("hello world");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { useState } from "react";
|
||||
import { ChatInput } from "../ChatInput";
|
||||
import { ChatInputToolbar } from "../ChatInputToolbar";
|
||||
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
|
||||
const mockVoiceDictation = {
|
||||
|
|
@ -57,7 +58,7 @@ const TEST_PERSONAS: Persona[] = [
|
|||
function StatefulChatInput({
|
||||
onSend = vi.fn(),
|
||||
}: {
|
||||
onSend?: (text: string, personaId?: string) => void;
|
||||
onSend?: (text: string, personaId?: string) => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
|
||||
"builtin-solo",
|
||||
|
|
@ -289,6 +290,34 @@ describe("ChatInput", () => {
|
|||
expect(onCompactContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens compaction settings from the context usage popover", async () => {
|
||||
const user = userEvent.setup();
|
||||
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
|
||||
|
||||
render(
|
||||
<ChatInput
|
||||
onSend={vi.fn()}
|
||||
selectedProvider="goose"
|
||||
contextTokens={1536}
|
||||
contextLimit={8192}
|
||||
canCompactContext
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /context usage/i }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /settings/i }));
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: OPEN_SETTINGS_EVENT,
|
||||
detail: { section: "compaction" },
|
||||
}),
|
||||
);
|
||||
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hides the context usage control when the context limit is unavailable", () => {
|
||||
render(
|
||||
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={0} />,
|
||||
|
|
@ -299,6 +328,21 @@ describe("ChatInput", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the context usage control until usage is ready", () => {
|
||||
render(
|
||||
<ChatInput
|
||||
onSend={vi.fn()}
|
||||
contextTokens={1536}
|
||||
contextLimit={8192}
|
||||
isContextUsageReady={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /context usage/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stop button when streaming", () => {
|
||||
render(<ChatInput onSend={vi.fn()} onStop={vi.fn()} isStreaming />);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
|
|||
openPath: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function userMessage(text: string, overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "u1",
|
||||
|
|
@ -35,8 +33,6 @@ function assistantMessage(
|
|||
};
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState({ personas: [] });
|
||||
|
|
@ -81,6 +77,32 @@ describe("MessageBubble", () => {
|
|||
expect(screen.getByText("hello world")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compaction notifications as centered success messages", () => {
|
||||
const { container } = render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "s1",
|
||||
role: "system",
|
||||
created: Date.now(),
|
||||
content: [
|
||||
{
|
||||
type: "systemNotification",
|
||||
notificationType: "compaction",
|
||||
text: "Conversation compacted.",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: false,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Conversation compacted.")).toBeInTheDocument();
|
||||
expect(container.querySelector(".text-success")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders user text inside a muted bubble shell", () => {
|
||||
const { container } = render(
|
||||
<MessageBubble message={userMessage("hello world")} />,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const mockController = {
|
|||
availableProjects: [],
|
||||
handleProjectChange: vi.fn(),
|
||||
tokenState: { accumulatedTotal: 0, contextLimit: 0 },
|
||||
isContextUsageReady: false,
|
||||
};
|
||||
|
||||
vi.mock("@/shared/api/acp", () => ({
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function HomeComposer({
|
|||
}
|
||||
contextTokens={controller.tokenState.accumulatedTotal}
|
||||
contextLimit={controller.tokenState.contextLimit}
|
||||
isContextUsageReady={controller.isContextUsageReady}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function inventoryModelToOption(
|
|||
provider: model.family ?? undefined,
|
||||
providerId: provider?.providerId,
|
||||
providerName: provider?.providerName,
|
||||
contextLimit: model.contextLimit ?? undefined,
|
||||
recommended: model.recommended ?? false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
13
ui/goose2/src/features/settings/lib/settingsEvents.ts
Normal file
13
ui/goose2/src/features/settings/lib/settingsEvents.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export const OPEN_SETTINGS_EVENT = "goose:open-settings";
|
||||
|
||||
export function requestOpenSettings(section?: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OPEN_SETTINGS_EVENT, {
|
||||
detail: section ? { section } : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
47
ui/goose2/src/features/settings/ui/CompactionSettings.tsx
Normal file
47
ui/goose2/src/features/settings/ui/CompactionSettings.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons";
|
||||
import { GooseAutoCompactSettings } from "./GooseAutoCompactSettings";
|
||||
|
||||
export function CompactionSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const icon = getProviderIcon("goose", "size-6");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("compaction.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("compaction.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex size-6 items-center justify-center [&>*]:size-6">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="mt-2 block text-sm font-medium">
|
||||
{t("compaction.goose.label")}
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("compaction.goose.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-1 text-xxs font-medium text-success">
|
||||
<IconCheck className="size-3.5" />
|
||||
<span>{t("compaction.goose.builtIn")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<GooseAutoCompactSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
ui/goose2/src/features/settings/ui/GeneralSettings.tsx
Normal file
75
ui/goose2/src/features/settings/ui/GeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { type LocalePreference, useLocale } from "@/shared/i18n";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-8 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("general.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("general.description")}
|
||||
</p>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<SettingRow
|
||||
label={t("general.language.label")}
|
||||
description={t("general.language.description")}
|
||||
>
|
||||
<Select
|
||||
value={preference}
|
||||
onValueChange={(value) =>
|
||||
void setLocalePreference(value as LocalePreference)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">
|
||||
{t("general.language.system", {
|
||||
language: systemLocaleLabel,
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="en">{t("general.language.english")}</SelectItem>
|
||||
<SelectItem value="es">{t("general.language.spanish")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx
Normal file
128
ui/goose2/src/features/settings/ui/GooseAutoCompactSettings.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
import {
|
||||
autoCompactPercentToThreshold,
|
||||
autoCompactThresholdToPercent,
|
||||
clampAutoCompactThresholdPercent,
|
||||
} from "@/features/chat/lib/autoCompact";
|
||||
import { useAutoCompactPreferences } from "@/features/chat/hooks/useAutoCompactPreferences";
|
||||
import { Slider } from "@/shared/ui/slider";
|
||||
|
||||
export function GooseAutoCompactSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const {
|
||||
autoCompactThreshold,
|
||||
isHydrated: isAutoCompactThresholdHydrated,
|
||||
setAutoCompactThreshold,
|
||||
} = useAutoCompactPreferences();
|
||||
const autoCompactThresholdPercent =
|
||||
autoCompactThresholdToPercent(autoCompactThreshold);
|
||||
const [draftThresholdPercent, setDraftThresholdPercent] = useState(
|
||||
autoCompactThresholdPercent,
|
||||
);
|
||||
const [isSavingThreshold, setIsSavingThreshold] = useState(false);
|
||||
const [thresholdError, setThresholdError] = useState<string | null>(null);
|
||||
const translationKeyPrefix = "compaction.goose.autoCompact";
|
||||
const autoCompactValueLabel = !isAutoCompactThresholdHydrated
|
||||
? t(`${translationKeyPrefix}.loading`)
|
||||
: draftThresholdPercent >= 100
|
||||
? t(`${translationKeyPrefix}.off`)
|
||||
: formatNumber(draftThresholdPercent / 100, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDraftThresholdPercent(autoCompactThresholdPercent);
|
||||
}, [autoCompactThresholdPercent]);
|
||||
|
||||
const normalizeThresholdPercent = (value: number | undefined) =>
|
||||
clampAutoCompactThresholdPercent(value ?? autoCompactThresholdPercent);
|
||||
|
||||
const handleThresholdSliderChange = (values: number[]) => {
|
||||
const nextPercent = normalizeThresholdPercent(values[0]);
|
||||
setThresholdError(null);
|
||||
setDraftThresholdPercent(nextPercent);
|
||||
};
|
||||
|
||||
const saveThresholdPercent = async (nextPercent: number) => {
|
||||
if (isSavingThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThresholdError(null);
|
||||
setDraftThresholdPercent(nextPercent);
|
||||
if (nextPercent === autoCompactThresholdPercent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingThreshold(true);
|
||||
try {
|
||||
await setAutoCompactThreshold(autoCompactPercentToThreshold(nextPercent));
|
||||
} catch {
|
||||
setThresholdError(t(`${translationKeyPrefix}.saveError`));
|
||||
setDraftThresholdPercent(autoCompactThresholdPercent);
|
||||
} finally {
|
||||
setIsSavingThreshold(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThresholdSliderCommit = async (values: number[]) => {
|
||||
const nextPercent = normalizeThresholdPercent(values[0]);
|
||||
await saveThresholdPercent(nextPercent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{t(`${translationKeyPrefix}.label`)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`${translationKeyPrefix}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`${translationKeyPrefix}.current`)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground">
|
||||
{isSavingThreshold ? (
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="shrink-0 font-medium">
|
||||
{autoCompactValueLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
value={[draftThresholdPercent]}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={handleThresholdSliderChange}
|
||||
onValueCommit={(values) => {
|
||||
void handleThresholdSliderCommit(values);
|
||||
}}
|
||||
disabled={isSavingThreshold || !isAutoCompactThresholdHydrated}
|
||||
aria-label={t(`${translationKeyPrefix}.label`)}
|
||||
/>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(`${translationKeyPrefix}.helper`)}
|
||||
</p>
|
||||
|
||||
{thresholdError ? (
|
||||
<p className="text-[11px] text-destructive">{thresholdError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { type LocalePreference, useLocale } from "@/shared/i18n";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -13,15 +12,9 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import {
|
||||
Mic,
|
||||
Minimize2,
|
||||
Palette,
|
||||
Settings2,
|
||||
FolderKanban,
|
||||
|
|
@ -36,6 +29,8 @@ import { DoctorSettings } from "./DoctorSettings";
|
|||
import { ProvidersSettings } from "./ProvidersSettings";
|
||||
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
|
||||
import { VoiceInputSettings } from "./VoiceInputSettings";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { CompactionSettings } from "./CompactionSettings";
|
||||
import {
|
||||
listArchivedProjects,
|
||||
restoreProject,
|
||||
|
|
@ -51,6 +46,7 @@ import type { Session } from "@/shared/types/chat";
|
|||
const NAV_ITEMS = [
|
||||
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
||||
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
|
||||
{ id: "compaction", labelKey: "nav.compaction", icon: Minimize2 },
|
||||
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
|
||||
{ id: "voice", labelKey: "nav.voice", icon: Mic },
|
||||
{ id: "general", labelKey: "nav.general", icon: Settings2 },
|
||||
|
|
@ -72,7 +68,6 @@ export function SettingsModal({
|
|||
initialSection = "appearance",
|
||||
}: SettingsModalProps) {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
|
@ -243,55 +238,11 @@ export function SettingsModal({
|
|||
>
|
||||
{activeSection === "appearance" && <AppearanceSettings />}
|
||||
{activeSection === "providers" && <ProvidersSettings />}
|
||||
{activeSection === "compaction" && <CompactionSettings />}
|
||||
{activeSection === "extensions" && <ExtensionsSettings />}
|
||||
{activeSection === "voice" && <VoiceInputSettings />}
|
||||
{activeSection === "doctor" && <DoctorSettings />}
|
||||
{activeSection === "general" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("general.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("general.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("general.language.label")}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("general.language.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={preference}
|
||||
onValueChange={(value) =>
|
||||
void setLocalePreference(value as LocalePreference)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">
|
||||
{t("general.language.system", {
|
||||
language: systemLocaleLabel,
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="en">
|
||||
{t("general.language.english")}
|
||||
</SelectItem>
|
||||
<SelectItem value="es">
|
||||
{t("general.language.spanish")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "general" && <GeneralSettings />}
|
||||
{activeSection === "projects" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GooseAutoCompactSettings } from "../GooseAutoCompactSettings";
|
||||
|
||||
const mockSetAutoCompactThreshold = vi.fn();
|
||||
const mockUseAutoCompactPreferences = vi.fn();
|
||||
|
||||
vi.mock("@/shared/i18n", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/shared/i18n")>("@/shared/i18n");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocaleFormatting: () => ({
|
||||
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
new Intl.NumberFormat("en-US", options).format(value),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/features/chat/hooks/useAutoCompactPreferences", () => ({
|
||||
useAutoCompactPreferences: () => mockUseAutoCompactPreferences(),
|
||||
}));
|
||||
|
||||
describe("GooseAutoCompactSettings", () => {
|
||||
beforeEach(() => {
|
||||
mockSetAutoCompactThreshold.mockReset();
|
||||
mockSetAutoCompactThreshold.mockResolvedValue(undefined);
|
||||
mockUseAutoCompactPreferences.mockReset();
|
||||
mockUseAutoCompactPreferences.mockReturnValue({
|
||||
autoCompactThreshold: 0.8,
|
||||
isHydrated: true,
|
||||
setAutoCompactThreshold: mockSetAutoCompactThreshold,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the auto-compaction threshold from the slider", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GooseAutoCompactSettings />);
|
||||
|
||||
const slider = screen.getByRole("slider", {
|
||||
name: /auto-compact context/i,
|
||||
});
|
||||
slider.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSetAutoCompactThreshold).toHaveBeenCalledWith(0.81),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the slider interactive when auto-compaction is off", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAutoCompactPreferences.mockReturnValue({
|
||||
autoCompactThreshold: 1,
|
||||
isHydrated: true,
|
||||
setAutoCompactThreshold: mockSetAutoCompactThreshold,
|
||||
});
|
||||
|
||||
render(<GooseAutoCompactSettings />);
|
||||
|
||||
const slider = screen.getByRole("slider", {
|
||||
name: /auto-compact context/i,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||
expect(slider).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
slider.focus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSetAutoCompactThreshold).toHaveBeenCalledWith(0.99),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
ui/goose2/src/shared/lib/isPromiseLike.ts
Normal file
10
ui/goose2/src/shared/lib/isPromiseLike.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function isPromiseLike<T = unknown>(
|
||||
value: unknown,
|
||||
): value is PromiseLike<T> {
|
||||
return (
|
||||
(typeof value === "object" || typeof value === "function") &&
|
||||
value !== null &&
|
||||
"then" in value &&
|
||||
typeof value.then === "function"
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ function Slider({
|
|||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const thumbAriaLabel = props["aria-label"];
|
||||
const thumbAriaLabelledBy = props["aria-labelledby"];
|
||||
const isDisabled = props.disabled === true;
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
|
|
@ -51,6 +54,9 @@ function Slider({
|
|||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
aria-label={thumbAriaLabel}
|
||||
aria-labelledby={thumbAriaLabelledBy}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue