Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
import { Recipe } from '../recipe';
import MessageQueue from './MessageQueue';
import { detectInterruption } from '../utils/interruptionDetector';
import { getApiUrl } from '../config';

interface QueuedMessage {
id: string;
Expand Down Expand Up @@ -141,6 +142,7 @@ export default function ChatInput({
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
const [autoCompactThreshold, setAutoCompactThreshold] = useState<number>(0.8); // Default to 80%

// Draft functionality - get chat context and global draft context
// We need to handle the case where ChatInput is used without ChatProvider (e.g., in Hub)
Expand Down Expand Up @@ -497,6 +499,55 @@ export default function ChatInput({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentModel, currentProvider]);

// Load auto-compact threshold
const loadAutoCompactThreshold = useCallback(async () => {
try {
const secretKey = await window.electron.getSecretKey();
const response = await fetch(getApiUrl('/config/read'), {
method: 'POST',
headers: {
'X-Secret-Key': secretKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: 'GOOSE_AUTO_COMPACT_THRESHOLD',
is_secret: false,
}),
});
if (response.ok) {
const data = await response.json();
console.log('Loaded auto-compact threshold from config:', data);
if (data !== undefined && data !== null) {
setAutoCompactThreshold(data);
console.log('Set auto-compact threshold to:', data);
}
} else {
console.error('Failed to fetch auto-compact threshold, status:', response.status);
}
} catch (err) {
console.error('Error fetching auto-compact threshold:', err);
}
}, []);

useEffect(() => {
loadAutoCompactThreshold();
}, [loadAutoCompactThreshold]);

// Listen for threshold change events from AlertBox
useEffect(() => {
const handleThresholdChange = (event: CustomEvent<{ threshold: number }>) => {
setAutoCompactThreshold(event.detail.threshold);
};

// Type assertion to handle the mismatch between CustomEvent and EventListener
const eventListener = handleThresholdChange as (event: globalThis.Event) => void;
window.addEventListener('autoCompactThresholdChanged', eventListener);

return () => {
window.removeEventListener('autoCompactThresholdChanged', eventListener);
};
}, []);

// Handle tool count alerts and token usage
useEffect(() => {
clearAlerts();
Expand All @@ -522,6 +573,7 @@ export default function ChatInput({
handleManualCompaction(messages, setMessages, append, setAncestorMessages);
},
compactIcon: <ScrollText size={12} />,
autoCompactThreshold: autoCompactThreshold,
});
}

Expand All @@ -539,7 +591,16 @@ export default function ChatInput({
}
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, isCompacting, clearAlerts]);
}, [
numTokens,
toolCount,
tokenLimit,
isTokenLimitLoaded,
addAlert,
isCompacting,
clearAlerts,
autoCompactThreshold,
]);

// Cleanup effect for component unmount - prevent memory leaks
useEffect(() => {
Expand Down
229 changes: 211 additions & 18 deletions ui/desktop/src/components/alerts/AlertBox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { IoIosCloseCircle, IoIosWarning, IoIosInformationCircle } from 'react-icons/io';
import { FaPencilAlt, FaSave } from 'react-icons/fa';
import { cn } from '../../utils';
import { Alert, AlertType } from './types';
import { getApiUrl } from '../../config';

const alertIcons: Record<AlertType, React.ReactNode> = {
[AlertType.Error]: <IoIosCloseCircle className="h-5 w-5" />,
Expand All @@ -22,27 +24,218 @@ const alertStyles: Record<AlertType, string> = {
};

export const AlertBox = ({ alert, className }: AlertBoxProps) => {
const [isEditingThreshold, setIsEditingThreshold] = useState(false);
const [thresholdValue, setThresholdValue] = useState(
alert.autoCompactThreshold ? Math.round(alert.autoCompactThreshold * 100) : 80
);
const [isSaving, setIsSaving] = useState(false);

const handleSaveThreshold = async () => {
if (isSaving) return; // Prevent double-clicks

// Validate threshold value - allow 0 and 100 as special values to disable
const validThreshold = Math.max(0, Math.min(100, thresholdValue));
if (validThreshold !== thresholdValue) {
setThresholdValue(validThreshold);
}

setIsSaving(true);
try {
const newThreshold = validThreshold / 100; // Convert percentage to decimal
console.log('Saving auto-compact threshold:', newThreshold);

// Update the configuration via the upsert API
const response = await fetch(getApiUrl('/config/upsert'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
},
body: JSON.stringify({
key: 'GOOSE_AUTO_COMPACT_THRESHOLD',
value: newThreshold,
is_secret: false,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to update threshold: ${errorText}`);
}

const responseText = await response.text();
console.log('Threshold save response:', responseText);

setIsEditingThreshold(false);

// Dispatch a custom event to notify other components that the threshold has changed
// This allows ChatInput to reload the threshold without a page reload
window.dispatchEvent(
new CustomEvent('autoCompactThresholdChanged', {
detail: { threshold: newThreshold },
})
);

console.log('Dispatched autoCompactThresholdChanged event with threshold:', newThreshold);
} catch (error) {
console.error('Error saving threshold:', error);
window.alert(
`Failed to save threshold: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
setIsSaving(false);
}
};

return (
<div className={cn('flex flex-col gap-2 px-3 py-3', alertStyles[alert.type], className)}>
<div
className={cn('flex flex-col gap-2 px-3 py-3', alertStyles[alert.type], className)}
onMouseDown={(e) => {
// Prevent popover from closing when clicking inside the alert box
if (isEditingThreshold) {
e.stopPropagation();
}
}}
>
{alert.progress ? (
<div className="flex flex-col gap-2">
<span className="text-[11px]">{alert.message}</span>
<div className="flex justify-between w-full">
{[...Array(30)].map((_, i) => (
<div
key={i}
className={cn(
'h-[2px] w-[2px] rounded-full',
alert.type === AlertType.Info
? i < Math.round((alert.progress!.current / alert.progress!.total) * 30)
? 'dark:bg-black bg-white'
: 'dark:bg-black/20 bg-white/20'
: i < Math.round((alert.progress!.current / alert.progress!.total) * 30)
? 'bg-white'
: 'bg-white/20'
)}
/>
))}

{/* Auto-compact threshold indicator with edit */}
{alert.autoCompactThreshold !== undefined && (
<div className="flex items-center justify-center gap-1 min-h-[20px]">
{isEditingThreshold ? (
<>
<span className="text-[10px] opacity-70">Auto summarize at</span>
<input
type="number"
min="0"
max="100"
step="1"
value={thresholdValue}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
// Allow empty input for easier editing
if (e.target.value === '') {
setThresholdValue(0);
} else if (!isNaN(val)) {
// Clamp value between 0 and 100
setThresholdValue(Math.max(0, Math.min(100, val)));
}
}}
onBlur={(e) => {
// On blur, ensure we have a valid value
const val = parseInt(e.target.value, 10);
if (isNaN(val) || val < 0) {
setThresholdValue(0);
} else if (val > 100) {
setThresholdValue(100);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveThreshold();
} else if (e.key === 'Escape') {
setIsEditingThreshold(false);
setThresholdValue(
alert.autoCompactThreshold
? Math.round(alert.autoCompactThreshold * 100)
: 80
);
}
}}
onFocus={(e) => {
// Select all text on focus for easier editing
e.target.select();
}}
onClick={(e) => {
// Prevent issues with text selection
e.stopPropagation();
}}
className="w-12 px-1 text-[10px] bg-white/10 border border-current/30 rounded outline-none text-center focus:bg-white/20 focus:border-current/50 transition-colors"
disabled={isSaving}
autoFocus
/>
<span className="text-[10px] opacity-70">%</span>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSaveThreshold();
}}
disabled={isSaving}
className="p-1 hover:opacity-60 transition-opacity cursor-pointer relative z-50"
style={{ minWidth: '20px', minHeight: '20px', pointerEvents: 'auto' }}
>
<FaSave className="w-3 h-3" />
</button>
</>
) : (
<>
<span className="text-[10px] opacity-70">
{alert.autoCompactThreshold === 0 || alert.autoCompactThreshold === 1
? 'Auto summarize disabled'
: `Auto summarize at ${Math.round(alert.autoCompactThreshold * 100)}%`}
</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsEditingThreshold(true);
}}
className="p-1 hover:opacity-60 transition-opacity cursor-pointer relative z-10"
style={{ minWidth: '20px', minHeight: '20px' }}
>
<FaPencilAlt className="w-3 h-3 opacity-70" />
</button>
</>
)}
</div>
)}

<div className="flex justify-between w-full relative">
{[...Array(30)].map((_, i) => {
const progress = alert.progress!.current / alert.progress!.total;
const progressPercentage = Math.round(progress * 100);
const dotPosition = i / 29; // 0 to 1 range for 30 dots
const isActive = dotPosition <= progress;
const isThresholdDot =
alert.autoCompactThreshold !== undefined &&
alert.autoCompactThreshold > 0 &&
alert.autoCompactThreshold < 1 &&
Math.abs(dotPosition - alert.autoCompactThreshold) < 0.017; // ~1/30 tolerance

// Determine the color based on progress percentage
const getProgressColor = () => {
if (progressPercentage <= 50) {
return 'bg-green-500'; // Green for 0-50%
} else if (progressPercentage <= 75) {
return 'bg-yellow-500'; // Yellow for 51-75%
} else if (progressPercentage <= 90) {
return 'bg-orange-500'; // Orange for 76-90%
} else {
return 'bg-red-500'; // Red for 91-100%
}
};

const progressColor = getProgressColor();
const inactiveColor = 'bg-gray-300 dark:bg-gray-600';

return (
<div
key={i}
className={cn(
'rounded-full transition-all relative',
isThresholdDot
? 'h-[6px] w-[6px] -mt-[2px]' // Make threshold dot twice as large
: 'h-[2px] w-[2px]',
isActive ? progressColor : inactiveColor
)}
/>
);
})}
</div>
<div className="flex justify-between items-baseline text-[11px]">
<div className="flex gap-1 items-baseline">
Expand Down
1 change: 1 addition & 0 deletions ui/desktop/src/components/alerts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export interface Alert {
compactButtonDisabled?: boolean;
onCompact?: () => void;
compactIcon?: React.ReactNode;
autoCompactThreshold?: number; // Add this for showing the auto-compact threshold
}
21 changes: 17 additions & 4 deletions ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,34 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
}
}, [isHovered, isOpen, startHideTimer, wasAutoShown]);

// Handle click outside
// Handle click outside - but not when editing threshold
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
// Check if we're clicking on an input or button inside the popover
const target = event.target as HTMLElement;
const isInteractiveElement =
target.tagName === 'INPUT' ||
target.tagName === 'BUTTON' ||
target.closest('button') !== null ||
target.closest('input') !== null;

if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!isInteractiveElement
) {
setIsOpen(false);
setWasAutoShown(false);
}
};

if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
// Use mouseup instead of mousedown to allow button clicks to complete
document.addEventListener('mouseup', handleClickOutside);
}

return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('mouseup', handleClickOutside);
};
}, [isOpen]);

Expand Down
Loading