Replace compaction notifications with system notifications (#5218)

This commit is contained in:
David Katz
2025-10-22 16:09:44 -04:00
committed by GitHub
parent 755e9f893d
commit 23412e270f
31 changed files with 350 additions and 1060 deletions
+24 -103
View File
@@ -852,50 +852,6 @@
}
}
},
"/context/manage": {
"post": {
"tags": [
"Context Management"
],
"operationId": "manage_context",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContextManageRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Context managed successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContextManageResponse"
}
}
}
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"412": {
"description": "Precondition failed - Agent not available"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/diagnostics/{session_id}": {
"get": {
"tags": [
@@ -2195,69 +2151,12 @@
}
]
},
"ContextManageRequest": {
"type": "object",
"description": "Request payload for context management operations",
"required": [
"messages",
"sessionId"
],
"properties": {
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"description": "Collection of messages to be managed"
},
"sessionId": {
"type": "string",
"description": "Optional session ID for session-specific agent"
}
}
},
"ContextManageResponse": {
"type": "object",
"description": "Response from context management operations",
"required": [
"messages",
"tokenCounts"
],
"properties": {
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"description": "Processed messages after the operation"
},
"tokenCounts": {
"type": "array",
"items": {
"type": "integer",
"minimum": 0
},
"description": "Token counts for each processed message"
}
}
},
"Conversation": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
}
},
"ConversationCompacted": {
"type": "object",
"required": [
"msg"
],
"properties": {
"msg": {
"type": "string"
}
}
},
"CreateRecipeRequest": {
"type": "object",
"required": [
@@ -3249,7 +3148,7 @@
{
"allOf": [
{
"$ref": "#/components/schemas/ConversationCompacted"
"$ref": "#/components/schemas/SystemNotificationContent"
},
{
"type": "object",
@@ -3260,7 +3159,7 @@
"type": {
"type": "string",
"enum": [
"conversationCompacted"
"systemNotification"
]
}
}
@@ -4310,6 +4209,28 @@
"propertyName": "type"
}
},
"SystemNotificationContent": {
"type": "object",
"required": [
"notificationType",
"msg"
],
"properties": {
"msg": {
"type": "string"
},
"notificationType": {
"$ref": "#/components/schemas/SystemNotificationType"
}
}
},
"SystemNotificationType": {
"type": "string",
"enum": [
"thinkingMessage",
"inlineMessage"
]
},
"TextContent": {
"type": "object",
"required": [
+1 -12
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ManageContextData, ManageContextErrors, ManageContextResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -245,17 +245,6 @@ export const confirmPermission = <ThrowOnError extends boolean = false>(options:
});
};
export const manageContext = <ThrowOnError extends boolean = false>(options: Options<ManageContextData, ThrowOnError>) => {
return (options.client ?? client).post<ManageContextResponses, ManageContextErrors, ThrowOnError>({
url: '/context/manage',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const diagnostics = <ThrowOnError extends boolean = false>(options: Options<DiagnosticsData, ThrowOnError>) => {
return (options.client ?? client).get<DiagnosticsResponses, DiagnosticsErrors, ThrowOnError>({
url: '/diagnostics/{session_id}',
+9 -65
View File
@@ -67,40 +67,8 @@ export type ConfigResponse = {
export type Content = RawTextContent | RawImageContent | RawEmbeddedResource | RawAudioContent | RawResource;
/**
* Request payload for context management operations
*/
export type ContextManageRequest = {
/**
* Collection of messages to be managed
*/
messages: Array<Message>;
/**
* Optional session ID for session-specific agent
*/
sessionId: string;
};
/**
* Response from context management operations
*/
export type ContextManageResponse = {
/**
* Processed messages after the operation
*/
messages: Array<Message>;
/**
* Token counts for each processed message
*/
tokenCounts: Array<number>;
};
export type Conversation = Array<Message>;
export type ConversationCompacted = {
msg: string;
};
export type CreateRecipeRequest = {
author?: AuthorRequest | null;
session_id: string;
@@ -388,8 +356,8 @@ export type MessageContent = (TextContent & {
type: 'thinking';
}) | (RedactedThinkingContent & {
type: 'redactedThinking';
}) | (ConversationCompacted & {
type: 'conversationCompacted';
}) | (SystemNotificationContent & {
type: 'systemNotification';
});
/**
@@ -762,6 +730,13 @@ export type SuccessCheck = {
type: 'Shell';
};
export type SystemNotificationContent = {
msg: string;
notificationType: SystemNotificationType;
};
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage';
export type TextContent = {
_meta?: {
[key: string]: unknown;
@@ -1563,37 +1538,6 @@ export type ConfirmPermissionResponses = {
200: unknown;
};
export type ManageContextData = {
body: ContextManageRequest;
path?: never;
query?: never;
url: '/context/manage';
};
export type ManageContextErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Precondition failed - Agent not available
*/
412: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ManageContextResponses = {
/**
* Context managed successfully
*/
200: ContextManageResponse;
};
export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses];
export type DiagnosticsData = {
body?: never;
path: {
+5 -27
View File
@@ -46,11 +46,11 @@ import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { RecipeHeader } from './RecipeHeader';
import LoadingGoose from './LoadingGoose';
import { getThinkingMessage } from '../types/message';
import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { View, ViewOptions } from '../utils/navigationUtils';
import { ContextManagerProvider, useContextManager } from './context_management/ContextManager';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
@@ -115,7 +115,6 @@ function BaseChatContent({
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
const { isCompacting, handleManualCompaction } = useContextManager();
// Use shared chat engine
const {
@@ -382,26 +381,12 @@ function BaseChatContent({
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
{/* Action buttons for all errors including token limit errors */}
{/* Action button to retry last message */}
<div className="flex gap-2 mt-2">
<div
className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
clearError();
await handleManualCompaction(
messages,
setMessages,
append,
chat.sessionId
);
}}
>
Summarize Conversation
</div>
<div
className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
@@ -432,15 +417,13 @@ function BaseChatContent({
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(chatState !== ChatState.Idle || loadingChat || isCompacting) && (
{(chatState !== ChatState.Idle || loadingChat) && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
loadingChat
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
: getThinkingMessage(messages[messages.length - 1])
}
chatState={chatState}
/>
@@ -465,7 +448,6 @@ function BaseChatContent({
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
setMessages={setMessages}
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
@@ -515,9 +497,5 @@ function BaseChatContent({
}
export default function BaseChat(props: BaseChatProps) {
return (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
return <BaseChatContent {...props} />;
}
+5 -12
View File
@@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import LoadingGoose from './LoadingGoose';
import { getThinkingMessage } from '../types/message';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { ContextManagerProvider } from './context_management/ContextManager';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
@@ -168,8 +168,6 @@ function BaseChatContent({
const showPopularTopics =
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
// TODO(Douwe): get this from the backend
const isCompacting = false;
const chat: ChatType = {
messageHistoryIndex: 0,
@@ -183,11 +181,10 @@ function BaseChatContent({
// Map chatState to LoadingGoose message
const getLoadingMessage = (): string | undefined => {
if (isCompacting) return 'goose is compacting the conversation...';
if (messages.length === 0 && chatState === ChatState.Thinking) {
return 'loading conversation...';
}
return undefined;
return getThinkingMessage(messages[messages.length - 1]);
};
return (
<div className="h-full flex flex-col min-h-0">
@@ -258,7 +255,8 @@ function BaseChatContent({
) : null}
</ScrollArea>
{(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && (
{/* Fixed loading indicator at bottom left of chat container */}
{chatState !== ChatState.Idle && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose message={getLoadingMessage()} chatState={chatState} />
</div>
@@ -282,7 +280,6 @@ function BaseChatContent({
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
setMessages={(_m) => {}}
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
@@ -331,9 +328,5 @@ function BaseChatContent({
}
export default function BaseChat(props: BaseChatProps) {
return (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
return <BaseChatContent {...props} />;
}
+20 -27
View File
@@ -19,7 +19,6 @@ import { WaveformVisualizer } from './WaveformVisualizer';
import { toastError } from '../toasts';
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
import { useDictationSettings } from '../hooks/useDictationSettings';
import { useContextManager } from './context_management/ContextManager';
import { useChatContext } from '../contexts/ChatContext';
import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates';
import { CostTracker } from './bottom_menu/CostTracker';
@@ -52,6 +51,9 @@ const MAX_IMAGE_SIZE_MB = 5;
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
// Manual compact trigger message - must match backend constant
const MANUAL_COMPACT_TRIGGER = 'Please compact this conversation';
interface ModelLimit {
pattern: string;
context_limit: number;
@@ -71,7 +73,6 @@ interface ChatInputProps {
inputTokens?: number;
outputTokens?: number;
messages?: Message[];
setMessages: (messages: Message[]) => void;
sessionCosts?: {
[key: string]: {
inputTokens: number;
@@ -105,7 +106,6 @@ export default function ChatInput({
inputTokens,
outputTokens,
messages = [],
setMessages,
disableAnimation = false,
sessionCosts,
setIsGoosehintsModalOpen,
@@ -115,7 +115,7 @@ export default function ChatInput({
initialPrompt,
toolCount,
autoSubmit = false,
append,
append: _append,
isExtensionsLoading = false,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
@@ -137,7 +137,6 @@ export default function ChatInput({
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
null
) as React.RefObject<HTMLDivElement>;
const { isCompacting, handleManualCompaction } = useContextManager();
const { getProviders, read } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
@@ -523,9 +522,6 @@ export default function ChatInput({
// Show alert when either there is registered token usage, or we know the limit
if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) {
// in these conditions we want it to be present but disabled
const compactButtonDisabled = !numTokens || isCompacting;
addAlert({
type: AlertType.Info,
message: 'Context window',
@@ -534,12 +530,15 @@ export default function ChatInput({
total: tokenLimit,
},
showCompactButton: true,
compactButtonDisabled,
compactButtonDisabled: !numTokens,
onCompact: () => {
// Hide the alert popup by dispatching a custom event that the popover can listen to
// Importantly, this leaves the alert so the dot still shows up, but hides the popover
window.dispatchEvent(new CustomEvent('hide-alert-popover'));
handleManualCompaction(messages, setMessages, append, sessionId || '');
const customEvent = new CustomEvent('submit', {
detail: { value: MANUAL_COMPACT_TRIGGER },
}) as unknown as React.FormEvent;
handleSubmit(customEvent);
},
compactIcon: <ScrollText size={12} />,
autoCompactThreshold: autoCompactThreshold,
@@ -569,7 +568,6 @@ export default function ChatInput({
tokenLimit,
isTokenLimitLoaded,
addAlert,
isCompacting,
clearAlerts,
autoCompactThreshold,
]);
@@ -940,7 +938,6 @@ export default function ChatInput({
const canSubmit =
!isLoading &&
!isCompacting &&
agentIsReady &&
(displayValue.trim() ||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
@@ -1097,7 +1094,6 @@ export default function ChatInput({
e.preventDefault();
const canSubmit =
!isLoading &&
!isCompacting &&
agentIsReady &&
(displayValue.trim() ||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
@@ -1152,7 +1148,6 @@ export default function ChatInput({
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isCompacting ||
!agentIsReady ||
isExtensionsLoading;
@@ -1397,17 +1392,15 @@ export default function ChatInput({
<p>
{isExtensionsLoading
? 'Loading extensions...'
: isCompacting
? 'Compacting conversation...'
: isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: (chatContext?.agentWaitingMessage ?? 'Send')}
: isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: (chatContext?.agentWaitingMessage ?? 'Send')}
</p>
</TooltipContent>
</Tooltip>
@@ -18,8 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Message } from '../api';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { CompactionMarker } from './context_management/CompactionMarker';
import { useContextManager } from './context_management/ContextManager';
import { SystemNotificationInline } from './context_management/SystemNotificationInline';
import { NotificationEvent } from '../hooks/useMessageStream';
import LoadingGoose from './LoadingGoose';
import { ChatType } from '../types/chat';
@@ -68,17 +67,12 @@ export default function ProgressiveMessageList({
const hasOnlyToolResponses = (message: Message) =>
message.content.every((c) => c.type === 'toolResponse');
// Try to use context manager, but don't require it for session history
let hasCompactionMarker: ((message: Message) => boolean) | undefined;
try {
const contextManager = useContextManager();
hasCompactionMarker = contextManager.hasCompactionMarker;
} catch {
// Context manager not available (e.g., in session history view)
// This is fine, we'll just skip compaction marker functionality
hasCompactionMarker = undefined;
}
const hasInlineSystemNotification = (message: Message): boolean => {
return message.content.some(
(content) =>
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
);
};
// Simple progressive loading - start immediately when component mounts if needed
useEffect(() => {
@@ -187,6 +181,19 @@ export default function ProgressiveMessageList({
return null;
}
// System notifications are never user messages, handle them first
if (hasInlineSystemNotification(message)) {
return (
<div
key={message.id && `${message.id}-${message.content.length}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
data-testid="message-container"
>
<SystemNotificationInline message={message} />
</div>
);
}
const isUser = isUserMessage(message);
return (
@@ -196,37 +203,25 @@ export default function ProgressiveMessageList({
data-testid="message-container"
>
{isUser ? (
<>
{hasCompactionMarker && hasCompactionMarker(message) ? (
<CompactionMarker message={message} />
) : (
!hasOnlyToolResponses(message) && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
)}
</>
!hasOnlyToolResponses(message) && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
) : (
<>
{hasCompactionMarker && hasCompactionMarker(message) ? (
<CompactionMarker message={message} />
) : (
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
/>
)}
</>
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
/>
)}
</div>
);
@@ -243,7 +238,6 @@ export default function ProgressiveMessageList({
toolCallNotifications,
isStreamingMessage,
onMessageUpdate,
hasCompactionMarker,
]);
return (
@@ -1,17 +0,0 @@
import React from 'react';
import { Message, ConversationCompacted } from '../../api';
interface CompactionMarkerProps {
message: Message;
}
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
const compactionContent = message.content.find(
(content): content is ConversationCompacted & { type: 'conversationCompacted' } =>
content.type === 'conversationCompacted'
);
const markerText = compactionContent?.msg || 'Conversation compacted';
return <div className="text-xs text-gray-400 py-2 text-left">{markerText}</div>;
};
@@ -1,128 +0,0 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { manageContextFromBackend } from './index';
import { Message } from '../../api';
// Define the context management interface
interface ContextManagerState {
isCompacting: boolean;
compactionError: string | null;
}
interface ContextManagerActions {
handleManualCompaction: (
messages: Message[],
setMessages: (messages: Message[]) => void,
append?: (message: Message) => void,
sessionId?: string
) => Promise<void>;
hasCompactionMarker: (message: Message) => boolean;
}
// Create the context
const ContextManagerContext = createContext<
(ContextManagerState & ContextManagerActions) | undefined
>(undefined);
// Create the provider component
export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isCompacting, setIsCompacting] = useState<boolean>(false);
const [compactionError, setCompactionError] = useState<string | null>(null);
const performCompaction = useCallback(
async (
messages: Message[],
setMessages: (messages: Message[]) => void,
append: (message: Message) => void,
sessionId: string,
isManual: boolean = false
) => {
setIsCompacting(true);
setCompactionError(null);
try {
// Get the summary from the backend
const summaryResponse = await manageContextFromBackend({
messages: messages,
manageAction: 'summarize',
sessionId: sessionId,
});
setMessages(summaryResponse.messages);
// Only automatically submit the continuation message for auto-compaction (context limit reached)
// Manual compaction should just compact without continuing the conversation
if (!isManual) {
// Automatically submit the continuation message to continue the conversation
// This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text
const continuationMessage = summaryResponse.messages[2];
if (continuationMessage) {
setTimeout(() => {
append(continuationMessage);
}, 100);
}
}
setIsCompacting(false);
} catch (err) {
// TODO(Douwe): move this to the server
console.error('Error during compaction:', err);
setCompactionError(err instanceof Error ? err.message : 'Unknown error during compaction');
// Create an error marker
const errorMarker: Message = {
id: `compaction-error-${Date.now()}`,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'conversationCompacted',
msg: 'Compaction failed. Please try again or start a new session.',
},
],
metadata: { userVisible: true, agentVisible: true },
};
setMessages([...messages, errorMarker]);
setIsCompacting(false);
}
},
[]
);
const handleManualCompaction = useCallback(
async (
messages: Message[],
setMessages: (messages: Message[]) => void,
append?: (message: Message) => void,
sessionId?: string
) => {
await performCompaction(messages, setMessages, append || (() => {}), sessionId || '', true);
},
[performCompaction]
);
const hasCompactionMarker = useCallback((message: Message): boolean => {
return message.content.some((content) => content.type === 'conversationCompacted');
}, []);
const value = {
// State
isCompacting,
compactionError,
// Actions
handleManualCompaction,
hasCompactionMarker,
};
return <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
};
// Create a hook to use the context
export const useContextManager = () => {
const context = useContext(ContextManagerContext);
if (context === undefined) {
throw new Error('useContextManager must be used within a ContextManagerProvider');
}
return context;
};
@@ -0,0 +1,19 @@
import React from 'react';
import { Message, SystemNotificationContent } from '../../api';
interface SystemNotificationInlineProps {
message: Message;
}
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({ message }) => {
const systemNotification = message.content.find(
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
);
if (!systemNotification?.msg) {
return null;
}
return <div className="text-xs text-gray-400 py-2 text-left">{systemNotification.msg}</div>;
};
@@ -1,77 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CompactionMarker } from '../CompactionMarker';
import { Message } from '../../../api';
const default_message: Message = {
metadata: {
agentVisible: false,
userVisible: false,
},
id: '1',
role: 'assistant',
created: 1000,content: []
};
describe('CompactionMarker', () => {
it('should render default message when no conversationCompacted content found', () => {
const message: Message = {
...default_message,
content: [{ type: 'text', text: 'Regular message' }],
};
render(<CompactionMarker message={message} />);
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
it('should render custom message from conversationCompacted content', () => {
const message: Message = {
...default_message,
content: [
{ type: 'text', text: 'Some other content' },
{ type: 'conversationCompacted', msg: 'Custom compaction message' },
],
};
render(<CompactionMarker message={message} />);
expect(screen.getByText('Custom compaction message')).toBeInTheDocument();
});
it('should handle empty message content array', () => {
const message: Message = {
...default_message,
content: [],
};
render(<CompactionMarker message={message} />);
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
it('should handle summarizationRequested content with empty msg', () => {
const message: Message = {
...default_message,
content: [{ type: 'conversationCompacted', msg: '' }],
};
render(<CompactionMarker message={message} />);
// Empty string falls back to default due to || operator
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
it('should handle summarizationRequested content with undefined msg', () => {
const message: Message = {
...default_message,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: [{ type: 'conversationCompacted' } as any],
};
render(<CompactionMarker message={message} />);
// Should render the default message when msg is undefined
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
});
@@ -1,283 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { ContextManagerProvider, useContextManager } from '../ContextManager';
import * as contextManagement from '../index';
import { Message } from '../../../api';
const default_message: Message = {
metadata: {
agentVisible: false,
userVisible: false,
},
id: '1',
role: 'assistant',
created: 1000,
content: [],
};
// Mock the context management functions
vi.mock('../index', () => ({
manageContextFromBackend: vi.fn(),
}));
const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend);
describe('ContextManager', () => {
const mockMessages: Message[] = [
{
...default_message,
content: [{ type: 'text', text: 'Hello' }],
},
{
...default_message,
content: [{ type: 'text', text: 'Hi there!' }],
},
];
const mockSetMessages = vi.fn();
const mockAppend = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const renderContextManager = () => {
return renderHook(() => useContextManager(), {
wrapper: ({ children }) => <ContextManagerProvider>{children}</ContextManagerProvider>,
});
};
describe('Initial State', () => {
it('should have correct initial state', () => {
const { result } = renderContextManager();
expect(result.current.isCompacting).toBe(false);
expect(result.current.compactionError).toBe(null);
});
});
describe('hasCompactionMarker', () => {
it('should return true for messages with summarizationRequested content', () => {
const { result } = renderContextManager();
const messageWithMarker: Message = {
...default_message,
content: [{ type: 'conversationCompacted', msg: 'Compaction marker' }],
};
expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true);
});
it('should return false for messages without summarizationRequested content', () => {
const { result } = renderContextManager();
const regularMessage: Message = {
...default_message,
content: [{ type: 'text', text: 'Hello' }],
};
expect(result.current.hasCompactionMarker(regularMessage)).toBe(false);
});
it('should return true for messages with mixed content including conversationCompacted', () => {
const { result } = renderContextManager();
const mixedMessage: Message = {
...default_message,
content: [
{ type: 'text', text: 'Some text' },
{ type: 'conversationCompacted', msg: 'Compaction marker' },
],
};
expect(result.current.hasCompactionMarker(mixedMessage)).toBe(true);
});
});
describe('handleManualCompaction', () => {
it('should perform compaction with server-provided messages', async () => {
mockManageContextFromBackend.mockResolvedValue({
messages: [
{
...default_message,
content: [
{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' },
],
},
{
...default_message,
content: [{ type: 'text', text: 'Manual summary content' }],
},
{
...default_message,
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
},
],
tokenCounts: [8, 100, 50],
});
const { result } = renderContextManager();
await act(async () => {
await result.current.handleManualCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
messages: mockMessages,
manageAction: 'summarize',
sessionId: 'test-session-id',
});
// Verify all three messages are set
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(3);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }],
});
expect(setMessagesCall[1]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Manual summary content' }],
});
expect(setMessagesCall[2]).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
// Fast-forward timers to check if append would be called
act(() => {
vi.advanceTimersByTime(150);
});
// Should NOT append the continuation message for manual compaction
expect(mockAppend).not.toHaveBeenCalled();
});
it('should work without append function', async () => {
mockManageContextFromBackend.mockResolvedValue({
messages: [
{
...default_message,
content: [{ type: 'text', text: 'Manual summary content' }],
},
],
tokenCounts: [100, 50],
});
const { result } = renderContextManager();
await act(async () => {
await result.current.handleManualCompaction(
mockMessages,
mockSetMessages,
undefined // No append function
);
});
expect(mockManageContextFromBackend).toHaveBeenCalled();
// Should not throw error when append is undefined
// Fast-forward timers to check if append would be called
act(() => {
vi.advanceTimersByTime(150);
});
// No append function provided, so no calls should be made
expect(mockAppend).not.toHaveBeenCalled();
});
it('should not auto-continue conversation for manual compaction even with append function', async () => {
mockManageContextFromBackend.mockResolvedValue({
messages: [
{
...default_message,
content: [
{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' },
],
},
{
...default_message,
content: [{ type: 'text', text: 'Manual summary content' }],
},
{
...default_message,
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
},
],
tokenCounts: [8, 100, 50],
});
const { result } = renderContextManager();
await act(async () => {
await result.current.handleManualCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
// Verify all three messages are set
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(3);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }],
});
expect(setMessagesCall[1]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Manual summary content' }],
});
expect(setMessagesCall[2]).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
// Fast-forward timers to check if append would be called
act(() => {
vi.advanceTimersByTime(150);
});
// Should NOT auto-continue for manual compaction, even with append function
expect(mockAppend).not.toHaveBeenCalled();
});
});
describe('Context Provider Error', () => {
it('should throw error when useContextManager is used outside provider', () => {
expect(() => {
renderHook(() => useContextManager());
}).toThrow('useContextManager must be used within a ContextManagerProvider');
});
});
});
@@ -1,29 +0,0 @@
import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api';
export async function manageContextFromBackend({
messages,
manageAction,
sessionId,
}: {
messages: Message[];
manageAction: 'truncation' | 'summarize';
sessionId: string;
}): Promise<ContextManageResponse> {
const contextManagementRequest = { manageAction, messages, sessionId };
// Cast to the API-expected type
const result = await manageContext({
body: contextManagementRequest as unknown as ContextManageRequest,
});
// Check for errors in the result
if (result.error) {
throw new Error(`Context management failed: ${result.error}`);
}
if (!result.data) {
throw new Error('Context management returned no data');
}
return result.data;
}
+26 -30
View File
@@ -17,7 +17,6 @@
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { ChatState } from '../types/chatState';
import { ContextManagerProvider } from './context_management/ContextManager';
import 'react-toastify/dist/ReactToastify.css';
import { View, ViewOptions } from '../utils/navigationUtils';
import { startAgent } from '../api';
@@ -65,35 +64,32 @@ export default function Hub({
};
return (
<ContextManagerProvider>
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<SessionInsights />
</div>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
numTokens={0}
inputTokens={0}
outputTokens={0}
droppedFiles={[]}
onFilesProcessed={() => {}}
messages={[]}
setMessages={() => {}}
disableAnimation={false}
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<SessionInsights />
</div>
</ContextManagerProvider>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
numTokens={0}
inputTokens={0}
outputTokens={0}
droppedFiles={[]}
onFilesProcessed={() => {}}
messages={[]}
disableAnimation={false}
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
</div>
);
}
@@ -28,7 +28,6 @@ import {
} from '../ui/dialog';
import ProgressiveMessageList from '../ProgressiveMessageList';
import { SearchView } from '../conversation/SearchView';
import { ContextManagerProvider } from '../context_management/ContextManager';
import BackButton from '../ui/BackButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { Message, Session } from '../../api';
@@ -101,29 +100,27 @@ const SessionMessages: React.FC<{
</Button>
</div>
) : filteredMessages?.length > 0 ? (
<ContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView placeholder="Search history...">
<ProgressiveMessageList
messages={filteredMessages}
chat={{
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
appendMessage={(newMessage) => {
// Read-only - do nothing
console.log('appendMessage called in read-only session history:', newMessage);
}}
isUserMessage={isUserMessage} // Use the same function as BaseChat
batchSize={15} // Same as BaseChat default
batchDelay={30} // Same as BaseChat default
showLoadingThreshold={30} // Same as BaseChat default
/>
</SearchView>
</div>
</ContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView placeholder="Search history...">
<ProgressiveMessageList
messages={filteredMessages}
chat={{
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
appendMessage={(newMessage) => {
// Read-only - do nothing
console.log('appendMessage called in read-only session history:', newMessage);
}}
isUserMessage={isUserMessage} // Use the same function as BaseChat
batchSize={15} // Same as BaseChat default
batchDelay={30} // Same as BaseChat default
showLoadingThreshold={30} // Same as BaseChat default
/>
</SearchView>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquareText className="w-12 h-12 mb-4" />
+5 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
import useSWR from 'swr';
import { createUserMessage, hasCompletedToolCalls } from '../types/message';
import { createUserMessage, getThinkingMessage, hasCompletedToolCalls } from '../types/message';
import { Conversation, Message, Role } from '../api';
import { getSession, Session } from '../api';
@@ -307,6 +307,10 @@ export function useMessageStream({
mutateChatState(ChatState.WaitingForUserInput);
}
if (getThinkingMessage(newMessage)) {
mutateChatState(ChatState.Thinking);
}
mutate(currentMessages, false);
break;
}
+14
View File
@@ -70,3 +70,17 @@ export function hasCompletedToolCalls(message: Message): boolean {
const toolRequests = getToolRequests(message);
return toolRequests.length > 0;
}
export function getThinkingMessage(message: Message | undefined): string | undefined {
if (!message || message.role !== 'assistant') {
return undefined;
}
for (const content of message.content) {
if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') {
return content.msg;
}
}
return undefined;
}