Multi chat (#6428)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../constants/events';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -7,7 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import PopularChatTopics from './PopularChatTopics';
|
||||
@@ -36,11 +37,10 @@ import { substituteParameters } from '../utils/providerUtils';
|
||||
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
|
||||
import { toastSuccess } from '../toasts';
|
||||
import { Recipe } from '../recipe';
|
||||
import { createSession } from '../sessions';
|
||||
import { getInitialWorkingDir } from '../utils/workingDir';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { useAutoSubmit } from '../hooks/useAutoSubmit';
|
||||
import { Goose } from './icons/Goose';
|
||||
import EnvironmentBadge from './GooseSidebar/EnvironmentBadge';
|
||||
|
||||
// Context for sharing current model info
|
||||
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
|
||||
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
|
||||
|
||||
@@ -55,6 +55,7 @@ interface BaseChatProps {
|
||||
showPopularTopics?: boolean;
|
||||
suppressEmptyState: boolean;
|
||||
sessionId: string;
|
||||
isActiveSession: boolean;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
@@ -65,37 +66,31 @@ function BaseChatContent({
|
||||
customMainLayoutProps = {},
|
||||
sessionId,
|
||||
initialMessage,
|
||||
isActiveSession,
|
||||
}: BaseChatProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
const { extensionsList } = useConfig();
|
||||
const chatInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const disableAnimation = location.state?.disableAnimation || false;
|
||||
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
||||
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
|
||||
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const setView = useNavigation();
|
||||
|
||||
const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11');
|
||||
|
||||
// Use shared file drop
|
||||
const contentClassName = cn(
|
||||
'pr-1 pb-10 pt-10',
|
||||
(isMobile || sidebarState === 'collapsed') && 'pt-14'
|
||||
);
|
||||
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
|
||||
|
||||
const onStreamFinish = useCallback(() => {}, []);
|
||||
|
||||
const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false);
|
||||
const hasAutoSubmittedRef = useRef(false);
|
||||
|
||||
// Reset auto-submit flag when session changes
|
||||
useEffect(() => {
|
||||
hasAutoSubmittedRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
const {
|
||||
session,
|
||||
@@ -115,6 +110,40 @@ function BaseChatContent({
|
||||
onStreamFinish,
|
||||
});
|
||||
|
||||
useAutoSubmit({
|
||||
sessionId,
|
||||
session,
|
||||
messages,
|
||||
chatState,
|
||||
initialMessage,
|
||||
handleSubmit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let streamState: 'idle' | 'loading' | 'streaming' | 'error' = 'idle';
|
||||
if (chatState === ChatState.LoadingConversation) {
|
||||
streamState = 'loading';
|
||||
} else if (
|
||||
chatState === ChatState.Streaming ||
|
||||
chatState === ChatState.Thinking ||
|
||||
chatState === ChatState.Compacting
|
||||
) {
|
||||
streamState = 'streaming';
|
||||
} else if (sessionLoadError) {
|
||||
streamState = 'error';
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_STATUS_UPDATE, {
|
||||
detail: {
|
||||
sessionId,
|
||||
streamState,
|
||||
messageCount: messages.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [sessionId, chatState, messages.length, sessionLoadError]);
|
||||
|
||||
// Generate command history from user messages (most recent first)
|
||||
const commandHistory = useMemo(() => {
|
||||
return messages
|
||||
@@ -130,48 +159,10 @@ function BaseChatContent({
|
||||
.reverse();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || hasAutoSubmittedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true';
|
||||
|
||||
if (initialMessage) {
|
||||
hasAutoSubmittedRef.current = true;
|
||||
handleSubmit(initialMessage);
|
||||
// Clear initialMessage from navigation state to prevent re-sending on refresh
|
||||
navigate(location.pathname + location.search, {
|
||||
replace: true,
|
||||
state: { ...location.state, initialMessage: undefined },
|
||||
});
|
||||
} else if (shouldStartAgent) {
|
||||
hasAutoSubmittedRef.current = true;
|
||||
handleSubmit('');
|
||||
}
|
||||
}, [session, initialMessage, searchParams, handleSubmit, navigate, location]);
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const textValue = customEvent.detail?.value || '';
|
||||
|
||||
// If no session exists, create one and navigate with the initial message
|
||||
if (!session && !sessionId && textValue.trim() && !isCreatingSession) {
|
||||
setIsCreatingSession(true);
|
||||
try {
|
||||
const newSession = await createSession(getInitialWorkingDir(), {
|
||||
allExtensions: extensionsList,
|
||||
});
|
||||
navigate(`/pair?resumeSessionId=${newSession.id}`, {
|
||||
replace: true,
|
||||
state: { resumeSessionId: newSession.id, initialMessage: textValue },
|
||||
});
|
||||
} catch {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (recipe && textValue.trim()) {
|
||||
setHasStartedUsingRecipe(true);
|
||||
}
|
||||
@@ -242,10 +233,26 @@ function BaseChatContent({
|
||||
}, 200);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
|
||||
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
|
||||
window.addEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest);
|
||||
return () =>
|
||||
window.removeEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isActiveSession &&
|
||||
sessionId &&
|
||||
chatInputRef.current &&
|
||||
chatState !== ChatState.LoadingConversation
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
chatInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
return undefined;
|
||||
}, [isActiveSession, sessionId, chatState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMakeAgent = () => {
|
||||
setIsCreateRecipeModalOpen(true);
|
||||
@@ -262,6 +269,7 @@ function BaseChatContent({
|
||||
shouldStartAgent?: boolean;
|
||||
editedMessage?: string;
|
||||
}>;
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||
const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -278,10 +286,10 @@ function BaseChatContent({
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('session-forked', handleSessionForked);
|
||||
window.addEventListener(AppEvents.SESSION_FORKED, handleSessionForked);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('session-forked', handleSessionForked);
|
||||
window.removeEventListener(AppEvents.SESSION_FORKED, handleSessionForked);
|
||||
};
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
@@ -373,6 +381,12 @@ function BaseChatContent({
|
||||
|
||||
{/* Chat container with sticky recipe header */}
|
||||
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
|
||||
<div className="absolute top-3 right-4 z-20 flex flex-row items-center gap-1">
|
||||
<Goose className="size-5 goose-icon-animation" />
|
||||
<span className="text-sm leading-none text-text-muted -translate-y-px">goose</span>
|
||||
<EnvironmentBadge className="translate-y-px" />
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
ref={scrollRef}
|
||||
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
|
||||
@@ -419,15 +433,7 @@ function BaseChatContent({
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
) : !recipe && showPopularTopics ? (
|
||||
<PopularChatTopics
|
||||
append={(text: string) => {
|
||||
const syntheticEvent = {
|
||||
detail: { value: text },
|
||||
preventDefault: () => {},
|
||||
} as unknown as React.FormEvent;
|
||||
handleFormSubmit(syntheticEvent);
|
||||
}}
|
||||
/>
|
||||
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -449,6 +455,7 @@ function BaseChatContent({
|
||||
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
||||
>
|
||||
<ChatInput
|
||||
inputRef={chatInputRef}
|
||||
sessionId={sessionId}
|
||||
handleSubmit={handleFormSubmit}
|
||||
chatState={chatState}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../constants/events';
|
||||
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Bug, ScrollText, ChefHat } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip';
|
||||
@@ -101,6 +102,7 @@ interface ChatInputProps {
|
||||
toolCount: number;
|
||||
append?: (message: Message) => void;
|
||||
onWorkingDirChange?: (newDir: string) => void;
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -127,6 +129,7 @@ export default function ChatInput({
|
||||
toolCount,
|
||||
append: _append,
|
||||
onWorkingDirChange,
|
||||
inputRef,
|
||||
}: ChatInputProps) {
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
@@ -298,30 +301,27 @@ export default function ChatInput({
|
||||
},
|
||||
});
|
||||
|
||||
// Get dictation settings to check configuration status
|
||||
const { settings: dictationSettings } = useDictationSettings();
|
||||
const internalTextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textAreaRef = inputRef || internalTextAreaRef;
|
||||
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
// Update internal value when initialValue changes
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
setDisplayValue(initialValue);
|
||||
|
||||
// Use a functional update to get the current pastedImages
|
||||
// and perform cleanup. This avoids needing pastedImages in the deps.
|
||||
setPastedImages((currentPastedImages) => {
|
||||
currentPastedImages.forEach((img) => {
|
||||
if (img.filePath) {
|
||||
window.electron.deleteTempFile(img.filePath);
|
||||
}
|
||||
});
|
||||
return []; // Return a new empty array
|
||||
return [];
|
||||
});
|
||||
|
||||
// Reset history index when input is cleared
|
||||
setHistoryIndex(-1);
|
||||
setIsInGlobalHistory(false);
|
||||
setHasUserTyped(false);
|
||||
}, [initialValue]); // Keep only initialValue as a dependency
|
||||
}, [initialValue]);
|
||||
|
||||
// Handle recipe prompt updates
|
||||
useEffect(() => {
|
||||
@@ -333,16 +333,13 @@ export default function ChatInput({
|
||||
textAreaRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [recipeAccepted, initialPrompt, messages.length]);
|
||||
}, [recipeAccepted, initialPrompt, messages.length, textAreaRef]);
|
||||
|
||||
// State to track if the IME is composing (i.e., in the middle of Japanese IME input)
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [savedInput, setSavedInput] = useState('');
|
||||
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
|
||||
const [hasUserTyped, setHasUserTyped] = useState(false);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
// Use shared file drop hook for ChatInput
|
||||
const {
|
||||
@@ -410,7 +407,7 @@ export default function ChatInput({
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
}, [textAreaRef]);
|
||||
|
||||
// Load model limits from the API
|
||||
const getModelLimits = async () => {
|
||||
@@ -514,7 +511,7 @@ export default function ChatInput({
|
||||
showCompactButton: true,
|
||||
compactButtonDisabled: !totalTokens,
|
||||
onCompact: () => {
|
||||
window.dispatchEvent(new CustomEvent('hide-alert-popover'));
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.HIDE_ALERT_POPOVER));
|
||||
|
||||
const customEvent = new CustomEvent('submit', {
|
||||
detail: { value: MANUAL_COMPACT_TRIGGER },
|
||||
@@ -579,28 +576,38 @@ export default function ChatInput({
|
||||
setValue(value);
|
||||
}, []);
|
||||
|
||||
const minTextareaHeight = 38;
|
||||
|
||||
const debouncedAutosize = useMemo(
|
||||
() =>
|
||||
debounce((element: HTMLTextAreaElement) => {
|
||||
element.style.height = '0px'; // Reset height
|
||||
// Store current scroll position to prevent jump
|
||||
const scrollTop = element.scrollTop;
|
||||
|
||||
// Temporarily set to auto to measure natural height, but use minHeight to prevent collapse
|
||||
element.style.height = `${minTextareaHeight}px`;
|
||||
const scrollHeight = element.scrollHeight;
|
||||
element.style.height = Math.min(scrollHeight, maxHeight) + 'px';
|
||||
const newHeight = Math.max(minTextareaHeight, Math.min(scrollHeight, maxHeight));
|
||||
element.style.height = `${newHeight}px`;
|
||||
|
||||
// Restore scroll position
|
||||
element.scrollTop = scrollTop;
|
||||
}, 50),
|
||||
[maxHeight]
|
||||
[maxHeight, minTextareaHeight]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current) {
|
||||
debouncedAutosize(textAreaRef.current);
|
||||
}
|
||||
}, [debouncedAutosize, displayValue]);
|
||||
}, [debouncedAutosize, displayValue, textAreaRef]);
|
||||
|
||||
// Reset textarea height when displayValue is empty
|
||||
// Set consistent minimum height when displayValue is empty
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current && displayValue === '') {
|
||||
textAreaRef.current.style.height = 'auto';
|
||||
textAreaRef.current.style.height = `${minTextareaHeight}px`;
|
||||
}
|
||||
}, [displayValue]);
|
||||
}, [displayValue, textAreaRef, minTextareaHeight]);
|
||||
|
||||
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = evt.target.value;
|
||||
@@ -1146,6 +1153,16 @@ export default function ChatInput({
|
||||
isTranscribing ||
|
||||
chatState === ChatState.RestartingAgent;
|
||||
|
||||
const getSubmitButtonTooltip = (): string => {
|
||||
if (isAnyImageLoading) return 'Waiting for images to save...';
|
||||
if (isAnyDroppedFileLoading) return 'Processing dropped files...';
|
||||
if (isRecording) return 'Recording...';
|
||||
if (isTranscribing) return 'Transcribing...';
|
||||
if (chatState === ChatState.RestartingAgent) return 'Restarting session...';
|
||||
if (!hasSubmittableContent) return 'Type a message to send';
|
||||
return 'Send';
|
||||
};
|
||||
|
||||
// Queue management functions - no storage persistence, only in-memory
|
||||
const handleRemoveQueuedMessage = (messageId: string) => {
|
||||
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
||||
@@ -1243,8 +1260,8 @@ export default function ChatInput({
|
||||
/>
|
||||
)}
|
||||
{/* Input row with inline action buttons wrapped in form */}
|
||||
<form onSubmit={onFormSubmit} className="relative flex items-end">
|
||||
<div className="relative flex-1">
|
||||
<form onSubmit={onFormSubmit} className="relative">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
data-testid="chat-input"
|
||||
autoFocus
|
||||
@@ -1261,14 +1278,15 @@ export default function ChatInput({
|
||||
ref={textAreaRef}
|
||||
rows={1}
|
||||
style={{
|
||||
minHeight: `${minTextareaHeight}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
overflowY: 'auto',
|
||||
opacity: isRecording ? 0 : 1,
|
||||
}}
|
||||
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-20 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
||||
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-32 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
||||
/>
|
||||
{isRecording && (
|
||||
<div className="absolute inset-0 flex items-center pl-4 pr-20 pt-3 pb-1.5">
|
||||
<div className="absolute inset-0 flex items-center pl-4 pr-32 pt-3 pb-1.5">
|
||||
<WaveformVisualizer
|
||||
audioContext={audioContext}
|
||||
analyser={analyser}
|
||||
@@ -1276,152 +1294,140 @@ export default function ChatInput({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline action buttons on the right */}
|
||||
<div className="flex items-center gap-1 px-2 relative self-center">
|
||||
{/* Microphone button - show only if dictation is enabled */}
|
||||
{dictationSettings?.enabled && (
|
||||
<>
|
||||
{!canUseDictation ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
onClick={() => {}}
|
||||
disabled={true}
|
||||
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
|
||||
>
|
||||
<Microphone />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{dictationSettings.provider === 'openai' ? (
|
||||
<p>
|
||||
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Models.</b>
|
||||
</p>
|
||||
) : VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
dictationSettings.provider === 'elevenlabs' ? (
|
||||
<p>
|
||||
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : dictationSettings.provider === null ? (
|
||||
<p>
|
||||
Dictation is not configured. Configure it in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : (
|
||||
<p>Dictation provider is not properly configured.</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
trackVoiceDictation('stop', Math.floor(recordingDuration));
|
||||
stopRecording();
|
||||
} else {
|
||||
trackVoiceDictation('start');
|
||||
startRecording();
|
||||
}
|
||||
}}
|
||||
disabled={isTranscribing}
|
||||
className={`rounded-full px-6 py-2 ${
|
||||
isRecording
|
||||
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
|
||||
: isTranscribing
|
||||
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
|
||||
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Microphone />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Send/Stop button */}
|
||||
{isLoading && !hasSubmittableContent ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
className="bg-slate-600 text-white hover:bg-slate-700 border-slate-600 rounded-full px-6 py-2"
|
||||
>
|
||||
<Stop />
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
{/* Inline action buttons - absolutely positioned on the right */}
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{/* Microphone button - show only if dictation is enabled */}
|
||||
{dictationSettings?.enabled && (
|
||||
<>
|
||||
{!canUseDictation ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
onClick={() => {}}
|
||||
disabled={true}
|
||||
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
|
||||
>
|
||||
<Microphone />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{dictationSettings.provider === 'openai' ? (
|
||||
<p>
|
||||
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Models.</b>
|
||||
</p>
|
||||
) : VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
dictationSettings.provider === 'elevenlabs' ? (
|
||||
<p>
|
||||
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : dictationSettings.provider === null ? (
|
||||
<p>
|
||||
Dictation is not configured. Configure it in <b>Settings</b> {'>'}{' '}
|
||||
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
||||
</p>
|
||||
) : (
|
||||
<p>Dictation provider is not properly configured.</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
disabled={isSubmitButtonDisabled}
|
||||
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
|
||||
isSubmitButtonDisabled
|
||||
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
|
||||
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
trackVoiceDictation('stop', Math.floor(recordingDuration));
|
||||
stopRecording();
|
||||
} else {
|
||||
trackVoiceDictation('start');
|
||||
startRecording();
|
||||
}
|
||||
}}
|
||||
disabled={isTranscribing}
|
||||
className={`rounded-full px-6 py-2 ${
|
||||
isRecording
|
||||
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
|
||||
: isTranscribing
|
||||
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
|
||||
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span className="text-sm">Send</span>
|
||||
<Microphone />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isAnyImageLoading
|
||||
? 'Waiting for images to save...'
|
||||
: isAnyDroppedFileLoading
|
||||
? 'Processing dropped files...'
|
||||
: isRecording
|
||||
? 'Recording...'
|
||||
: isTranscribing
|
||||
? 'Transcribing...'
|
||||
: chatState === ChatState.RestartingAgent
|
||||
? 'Restarting session...'
|
||||
: 'Send'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recording/transcribing status indicator - positioned above the button row */}
|
||||
{(isRecording || isTranscribing) && (
|
||||
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
|
||||
{isTranscribing ? (
|
||||
<span className="text-blue-500 flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
Transcribing...
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
|
||||
>
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
{Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB
|
||||
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Send/Stop button */}
|
||||
{isLoading && !hasSubmittableContent ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
className="bg-slate-600 text-white hover:bg-slate-700 border-slate-600 rounded-full px-6 py-2"
|
||||
>
|
||||
<Stop />
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
disabled={isSubmitButtonDisabled}
|
||||
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
|
||||
isSubmitButtonDisabled
|
||||
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
|
||||
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span className="text-sm">Send</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getSubmitButtonTooltip()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recording/transcribing status indicator - positioned above the button row */}
|
||||
{(isRecording || isTranscribing) && (
|
||||
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
|
||||
{isTranscribing ? (
|
||||
<span className="text-blue-500 flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
Transcribing...
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
|
||||
>
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
{Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB
|
||||
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import BaseChat from './BaseChat';
|
||||
import { ChatType } from '../types/chat';
|
||||
|
||||
interface ChatSessionsContainerProps {
|
||||
setChat: (chat: ChatType) => void;
|
||||
activeSessions: Array<{ sessionId: string; initialMessage?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container that mounts ALL active chat sessions to keep them alive.
|
||||
* Uses CSS to show/hide sessions based on the current URL parameter.
|
||||
* This allows multiple sessions to stream simultaneously in the background.
|
||||
*/
|
||||
export default function ChatSessionsContainer({
|
||||
setChat,
|
||||
activeSessions,
|
||||
}: ChatSessionsContainerProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const currentSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActiveSession = activeSessions.some((s) => s.sessionId === currentSessionId);
|
||||
|
||||
// If the current session isn't in active sessions, we need to render it anyway
|
||||
// (handles page refresh case)
|
||||
const sessionsToRender = isActiveSession
|
||||
? activeSessions
|
||||
: [...activeSessions, { sessionId: currentSessionId }];
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{sessionsToRender.map((session) => {
|
||||
const isVisible = session.sessionId === currentSessionId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className={`absolute inset-0 ${isVisible ? 'block' : 'hidden'}`}
|
||||
data-session-id={session.sessionId}
|
||||
>
|
||||
<BaseChat
|
||||
setChat={setChat}
|
||||
sessionId={session.sessionId}
|
||||
initialMessage={session.initialMessage}
|
||||
suppressEmptyState={false}
|
||||
isActiveSession={isVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FileText, Clock, Home, Puzzle, History, AppWindow } from 'lucide-react';
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
AppWindow,
|
||||
ChefHat,
|
||||
Clock,
|
||||
FileText,
|
||||
History,
|
||||
Home,
|
||||
MessageSquarePlus,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
} from '../ui/sidebar';
|
||||
import { ChatSmart, Gear } from '../icons';
|
||||
import { Goose } from '../icons/Goose';
|
||||
import { ViewOptions, View } from '../../utils/navigationUtils';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext';
|
||||
import EnvironmentBadge from './EnvironmentBadge';
|
||||
import { listApps } from '../../api';
|
||||
import { Gear } from '../icons';
|
||||
import { View, ViewOptions } from '../../utils/navigationUtils';
|
||||
import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext';
|
||||
import { listSessions, listApps, Session } from '../../api';
|
||||
import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import { SessionIndicators } from '../SessionIndicators';
|
||||
import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus';
|
||||
import { getInitialWorkingDir } from '../../utils/workingDir';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
@@ -42,29 +53,6 @@ interface NavigationSeparator {
|
||||
type NavigationEntry = NavigationItem | NavigationSeparator;
|
||||
|
||||
const menuItems: NavigationEntry[] = [
|
||||
{
|
||||
type: 'item',
|
||||
path: '/',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
tooltip: 'Go back to the main chat screen',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'item',
|
||||
path: '/pair',
|
||||
label: 'Chat',
|
||||
icon: ChatSmart,
|
||||
tooltip: 'Start pairing with Goose',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
path: '/sessions',
|
||||
label: 'History',
|
||||
icon: History,
|
||||
tooltip: 'View your session history',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'item',
|
||||
path: '/recipes',
|
||||
@@ -103,19 +91,165 @@ const menuItems: NavigationEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const getSessionDisplayName = (session: Session): string => {
|
||||
if (!shouldShowNewChatTitle(session)) {
|
||||
return session.name;
|
||||
}
|
||||
if (session.recipe?.title) {
|
||||
return session.recipe.title;
|
||||
}
|
||||
return DEFAULT_CHAT_TITLE;
|
||||
};
|
||||
|
||||
const SessionList = React.memo<{
|
||||
sessions: Session[];
|
||||
activeSessionId: string | undefined;
|
||||
getSessionStatus: (
|
||||
sessionId: string
|
||||
) => { streamState: string; hasUnreadActivity: boolean } | undefined;
|
||||
onSessionClick: (session: Session) => void;
|
||||
}>(
|
||||
({ sessions, activeSessionId, getSessionStatus, onSessionClick }) => {
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const aIsEmptyNew = shouldShowNewChatTitle(a);
|
||||
const bIsEmptyNew = shouldShowNewChatTitle(b);
|
||||
if (aIsEmptyNew && !bIsEmptyNew) return -1;
|
||||
if (!aIsEmptyNew && bIsEmptyNew) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
return (
|
||||
<div className="relative ml-3">
|
||||
{sortedSessions.map((session, index) => {
|
||||
const status = getSessionStatus(session.id);
|
||||
const isStreaming = status?.streamState === 'streaming';
|
||||
const hasError = status?.streamState === 'error';
|
||||
const hasUnread = status?.hasUnreadActivity ?? false;
|
||||
const displayName = getSessionDisplayName(session);
|
||||
const isLast = index === sortedSessions.length - 1;
|
||||
|
||||
return (
|
||||
<div key={session.id} className="relative flex items-center">
|
||||
{/* Vertical line segment - full height except last item stops at middle */}
|
||||
<div
|
||||
className={`absolute left-0 w-px bg-border-strong ${
|
||||
isLast ? 'top-0 h-1/2' : 'top-0 h-full'
|
||||
}`}
|
||||
/>
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute left-0 w-2 h-px bg-border-strong top-1/2" />
|
||||
<button
|
||||
onClick={() => onSessionClick(session)}
|
||||
className={`w-full text-left ml-3 px-1.5 py-1.5 pr-2 rounded-md text-sm transition-colors flex items-center gap-1 min-w-0 ${
|
||||
activeSessionId === session.id
|
||||
? 'bg-background-medium text-text-default'
|
||||
: 'text-text-muted hover:bg-background-medium/50 hover:text-text-default'
|
||||
}`}
|
||||
title={displayName}
|
||||
>
|
||||
{session.recipe && <ChefHat className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
<span className="flex-1 truncate min-w-0 block">{displayName}</span>
|
||||
<SessionIndicators
|
||||
isStreaming={isStreaming}
|
||||
hasUnread={hasUnread}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.sessions.length !== nextProps.sessions.length) return false;
|
||||
if (prevProps.activeSessionId !== nextProps.activeSessionId) return false;
|
||||
|
||||
const prevIds = prevProps.sessions.map((s) => s.id).join(',');
|
||||
const nextIds = nextProps.sessions.map((s) => s.id).join(',');
|
||||
if (prevIds !== nextIds) return false;
|
||||
|
||||
// Check if any session name or message_count changed
|
||||
for (let i = 0; i < prevProps.sessions.length; i++) {
|
||||
if (prevProps.sessions[i].name !== nextProps.sessions[i].name) return false;
|
||||
if (prevProps.sessions[i].message_count !== nextProps.sessions[i].message_count) return false;
|
||||
}
|
||||
|
||||
// Check if any session's status has changed
|
||||
for (const session of prevProps.sessions) {
|
||||
const prevStatus = prevProps.getSessionStatus(session.id);
|
||||
const nextStatus = nextProps.getSessionStatus(session.id);
|
||||
|
||||
if (prevStatus?.hasUnreadActivity !== nextStatus?.hasUnreadActivity) return false;
|
||||
if (prevStatus?.streamState !== nextStatus?.streamState) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
SessionList.displayName = 'SessionList';
|
||||
|
||||
const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const chatContext = useChatContext();
|
||||
const lastSessionIdRef = useRef<string | null>(null);
|
||||
const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null;
|
||||
const setView = useNavigation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
||||
const [hasApps, setHasApps] = useState(false);
|
||||
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||
const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId);
|
||||
|
||||
// When activeSessionId changes, ensure it's in the recent sessions list
|
||||
// This handles the case where a session is loaded from history that's older than the top 10
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
|
||||
const isInRecentSessions = recentSessions.some((s) => s.id === activeSessionId);
|
||||
if (isInRecentSessions) return;
|
||||
|
||||
// Fetch the active session and add it to the top of the list
|
||||
const fetchAndAddSession = async () => {
|
||||
try {
|
||||
const { getSession } = await import('../../api');
|
||||
const response = await getSession({ path: { session_id: activeSessionId } });
|
||||
if (response.data) {
|
||||
setRecentSessions((prev) => {
|
||||
// Don't add if it's already there (race condition check)
|
||||
if (prev.some((s) => s.id === activeSessionId)) return prev;
|
||||
// Add to the beginning and keep max 10
|
||||
return [response.data as Session, ...prev].slice(0, 10);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch active session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndAddSession();
|
||||
}, [activeSessionId, recentSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
const loadRecentSessions = async () => {
|
||||
try {
|
||||
const response = await listSessions<true>({ throwOnError: true });
|
||||
const sessions = response.data.sessions.slice(0, 10);
|
||||
setRecentSessions(sessions);
|
||||
|
||||
const hasSessionWithDefaultName = sessions.some((s) => shouldShowNewChatTitle(s));
|
||||
|
||||
if (hasSessionWithDefaultName) {
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_NEEDS_NAME_UPDATE));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent sessions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRecentSessions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkApps = async () => {
|
||||
@@ -132,6 +266,109 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
checkApps();
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let pollingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
let isPolling = false;
|
||||
|
||||
const handleSessionCreated = (event: Event) => {
|
||||
const { session } = (event as CustomEvent<{ session?: Session }>).detail;
|
||||
// If session data is provided, add it immediately to the sidebar
|
||||
// This is for displaying sessions that won't be returned by the API due to not having messages yet
|
||||
if (session) {
|
||||
setRecentSessions((prev) => {
|
||||
if (prev.some((s) => s.id === session.id)) return prev;
|
||||
return [session, ...prev].slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for updates to get the generated session name
|
||||
if (isPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
const pollIntervalMs = 300;
|
||||
const maxPollDurationMs = 10000;
|
||||
const maxPolls = maxPollDurationMs / pollIntervalMs;
|
||||
let pollCount = 0;
|
||||
|
||||
const pollForUpdates = async () => {
|
||||
pollCount++;
|
||||
|
||||
try {
|
||||
const response = await listSessions<true>({ throwOnError: true });
|
||||
const apiSessions = response.data.sessions.slice(0, 10);
|
||||
|
||||
// Merge API sessions with any locally-tracked empty sessions
|
||||
setRecentSessions((prev) => {
|
||||
const emptyLocalSessions = prev.filter(
|
||||
(local) =>
|
||||
local.message_count === 0 && !apiSessions.some((api) => api.id === local.id)
|
||||
);
|
||||
const merged = [...emptyLocalSessions, ...apiSessions];
|
||||
const seen = new Set<string>();
|
||||
return merged
|
||||
.filter((s) => {
|
||||
if (seen.has(s.id)) return false;
|
||||
seen.add(s.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
const sessionWithDefaultName = apiSessions.find((s) => shouldShowNewChatTitle(s));
|
||||
|
||||
const shouldContinue = pollCount < maxPolls && (sessionWithDefaultName || pollCount < 5);
|
||||
|
||||
if (shouldContinue) {
|
||||
const timeoutId = setTimeout(pollForUpdates, pollIntervalMs);
|
||||
pollingTimeouts.push(timeoutId);
|
||||
} else {
|
||||
isPolling = false;
|
||||
}
|
||||
} catch {
|
||||
isPolling = false;
|
||||
}
|
||||
};
|
||||
pollForUpdates();
|
||||
};
|
||||
|
||||
const handleSessionNeedsNameUpdate = () => {
|
||||
handleSessionCreated(new CustomEvent(AppEvents.SESSION_CREATED, { detail: {} }));
|
||||
};
|
||||
|
||||
const handleSessionDeleted = (event: Event) => {
|
||||
const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail;
|
||||
setRecentSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
};
|
||||
|
||||
const handleSessionRenamed = (event: Event) => {
|
||||
const { sessionId, newName } = (event as CustomEvent<{ sessionId: string; newName: string }>)
|
||||
.detail;
|
||||
setRecentSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === sessionId
|
||||
? { ...s, name: newName, message_count: Math.max(s.message_count, 1) }
|
||||
: s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(AppEvents.SESSION_CREATED, handleSessionCreated);
|
||||
window.addEventListener(AppEvents.SESSION_NEEDS_NAME_UPDATE, handleSessionNeedsNameUpdate);
|
||||
window.addEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted);
|
||||
window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(AppEvents.SESSION_CREATED, handleSessionCreated);
|
||||
window.removeEventListener(AppEvents.SESSION_NEEDS_NAME_UPDATE, handleSessionNeedsNameUpdate);
|
||||
window.removeEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted);
|
||||
window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed);
|
||||
pollingTimeouts.forEach(clearTimeout);
|
||||
isPolling = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentItem = menuItems.find(
|
||||
(item) => item.type === 'item' && item.path === currentPath
|
||||
@@ -156,16 +393,59 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
return currentPath === path;
|
||||
};
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
// For /pair, preserve the current session if one exists
|
||||
// Priority: current URL param > last known session > context
|
||||
const sessionId = currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId;
|
||||
if (path === '/pair' && sessionId && sessionId.length > 0) {
|
||||
navigate(`/pair?resumeSessionId=${sessionId}`);
|
||||
} else {
|
||||
navigate(path);
|
||||
// Use a ref to access the latest recentSessions without causing re-renders or dependency issues
|
||||
const recentSessionsRef = React.useRef(recentSessions);
|
||||
React.useEffect(() => {
|
||||
recentSessionsRef.current = recentSessions;
|
||||
}, [recentSessions]);
|
||||
|
||||
// Guard ref to prevent duplicate session creation from key commands
|
||||
const isCreatingSessionRef = React.useRef(false);
|
||||
|
||||
const handleNewChat = React.useCallback(async () => {
|
||||
if (isCreatingSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const emptyNewSession = recentSessionsRef.current.find((s) => shouldShowNewChatTitle(s));
|
||||
|
||||
if (emptyNewSession) {
|
||||
clearUnread(emptyNewSession.id);
|
||||
resumeSession(emptyNewSession, setView);
|
||||
} else {
|
||||
isCreatingSessionRef.current = true;
|
||||
try {
|
||||
await startNewSession('', setView, getInitialWorkingDir());
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isCreatingSessionRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, [setView, clearUnread]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTriggerNewChat = () => {
|
||||
handleNewChat();
|
||||
};
|
||||
|
||||
window.addEventListener(AppEvents.TRIGGER_NEW_CHAT, handleTriggerNewChat);
|
||||
return () => {
|
||||
window.removeEventListener(AppEvents.TRIGGER_NEW_CHAT, handleTriggerNewChat);
|
||||
};
|
||||
}, [handleNewChat]);
|
||||
|
||||
const handleSessionClick = React.useCallback(
|
||||
async (session: Session) => {
|
||||
clearUnread(session.id);
|
||||
resumeSession(session, setView);
|
||||
},
|
||||
[clearUnread, setView]
|
||||
);
|
||||
|
||||
const handleViewAllClick = React.useCallback(() => {
|
||||
navigate('/sessions');
|
||||
}, [navigate]);
|
||||
|
||||
const renderMenuItem = (entry: NavigationEntry, index: number) => {
|
||||
if (entry.type === 'separator') {
|
||||
@@ -175,13 +455,13 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
const IconComponent = entry.icon;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={entry.path}>
|
||||
<SidebarGroup key={entry.path} className="px-2">
|
||||
<SidebarGroupContent className="space-y-1">
|
||||
<div className="sidebar-item">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid={`sidebar-${entry.label.toLowerCase()}-button`}
|
||||
onClick={() => handleNavigation(entry.path)}
|
||||
onClick={() => navigate(entry.path)}
|
||||
isActive={isActivePath(entry.path)}
|
||||
tooltip={entry.tooltip}
|
||||
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
|
||||
@@ -205,19 +485,69 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarContent className="pt-16">
|
||||
<SidebarContent className="pt-12">
|
||||
<SidebarMenu>
|
||||
{/* Home and New Chat */}
|
||||
<SidebarGroup className="px-2">
|
||||
<SidebarGroupContent className="space-y-1">
|
||||
<div className="sidebar-item">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="sidebar-home-button"
|
||||
onClick={() => navigate('/')}
|
||||
isActive={isActivePath('/')}
|
||||
tooltip="Go back to the main chat screen"
|
||||
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<span>Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
<div className="sidebar-item">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="sidebar-new-chat-button"
|
||||
onClick={handleNewChat}
|
||||
tooltip="Start a new chat"
|
||||
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200"
|
||||
>
|
||||
<MessageSquarePlus className="w-4 h-4" />
|
||||
<span>Chat</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<SidebarGroup className="px-2">
|
||||
<SidebarGroupContent className="space-y-1">
|
||||
<SessionList
|
||||
sessions={recentSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
getSessionStatus={getSessionStatus}
|
||||
onSessionClick={handleSessionClick}
|
||||
/>
|
||||
{/* View All Link */}
|
||||
<button
|
||||
onClick={handleViewAllClick}
|
||||
className="w-full text-left px-3 py-1.5 rounded-md text-sm text-text-muted hover:bg-background-medium/50 hover:text-text-default transition-colors flex items-center gap-2"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<span>View All</span>
|
||||
</button>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Other menu items - filter out Apps if no apps available */}
|
||||
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="pb-6 px-3 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Goose className="size-14 goose-icon-animation" />
|
||||
<span className="text-base font-medium">goose</span>
|
||||
</div>
|
||||
<EnvironmentBadge />
|
||||
</SidebarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
|
||||
const EnvironmentBadge: React.FC = () => {
|
||||
interface EnvironmentBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EnvironmentBadge: React.FC<EnvironmentBadgeProps> = ({ className = '' }) => {
|
||||
const isAlpha = process.env.ALPHA;
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
|
||||
@@ -17,7 +21,7 @@ const EnvironmentBadge: React.FC = () => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`${bgColor} w-3 h-3 rounded-full cursor-default`}
|
||||
className={`${bgColor} w-2 h-2 rounded-full cursor-default ${className}`}
|
||||
data-testid="environment-badge"
|
||||
aria-label={tooltipText}
|
||||
/>
|
||||
|
||||
@@ -114,9 +114,9 @@ export function GroupedExtensionLoadingToast({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startNewSession(
|
||||
getInitialWorkingDir(),
|
||||
ext.recoverHints,
|
||||
setView
|
||||
setView,
|
||||
getInitialWorkingDir()
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../constants/events';
|
||||
/**
|
||||
* Hub Component
|
||||
*
|
||||
@@ -53,6 +54,13 @@ export default function Hub({
|
||||
allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
|
||||
detail: { sessionId: session.id, initialMessage: combinedTextFromInput },
|
||||
})
|
||||
);
|
||||
|
||||
setView('pair', {
|
||||
disableAnimation: true,
|
||||
resumeSessionId: session.id,
|
||||
|
||||
@@ -5,11 +5,15 @@ export default function LauncherView() {
|
||||
const [query, setQuery] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
window.electron.createChatWindow(query, getInitialWorkingDir());
|
||||
const initialMessage = query;
|
||||
setQuery('');
|
||||
window.electron.createChatWindow(initialMessage, getInitialWorkingDir());
|
||||
setTimeout(() => {
|
||||
window.electron.closeWindow();
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,13 +5,26 @@ import { View, ViewOptions } from '../../utils/navigationUtils';
|
||||
import { AppWindowMac, AppWindow } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
|
||||
import { getInitialWorkingDir } from '../../utils/workingDir';
|
||||
import ChatSessionsContainer from '../ChatSessionsContainer';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
|
||||
const AppLayoutContent: React.FC = () => {
|
||||
interface AppLayoutContentProps {
|
||||
activeSessions: Array<{ sessionId: string; initialMessage?: string }>;
|
||||
}
|
||||
|
||||
const AppLayoutContent: React.FC<AppLayoutContentProps> = ({ activeSessions }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
|
||||
const { isMobile, openMobile } = useSidebar();
|
||||
const chatContext = useChatContext();
|
||||
const isOnPairRoute = location.pathname === '/pair';
|
||||
|
||||
if (!chatContext) {
|
||||
throw new Error('AppLayoutContent must be used within ChatProvider');
|
||||
}
|
||||
|
||||
const { setChat } = chatContext;
|
||||
|
||||
// Calculate padding based on sidebar state and macOS
|
||||
const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4';
|
||||
@@ -67,7 +80,10 @@ const AppLayoutContent: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleNewWindow = () => {
|
||||
window.electron.createChatWindow(undefined, getInitialWorkingDir());
|
||||
window.electron.createChatWindow(
|
||||
undefined,
|
||||
window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,16 +112,27 @@ const AppLayoutContent: React.FC = () => {
|
||||
/>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<Outlet />
|
||||
{isOnPairRoute ? (
|
||||
<>
|
||||
<Outlet />
|
||||
<ChatSessionsContainer setChat={setChat} activeSessions={activeSessions} />
|
||||
</>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
interface AppLayoutProps {
|
||||
activeSessions: Array<{ sessionId: string; initialMessage?: string }>;
|
||||
}
|
||||
|
||||
export const AppLayout: React.FC<AppLayoutProps> = ({ activeSessions }) => {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppLayoutContent />
|
||||
<AppLayoutContent activeSessions={activeSessions} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../constants/events';
|
||||
import {
|
||||
UIResourceRenderer,
|
||||
UIActionResultIntent,
|
||||
@@ -142,7 +143,7 @@ export default function MCPUIResourceRenderer({
|
||||
if (appendPromptToChat) {
|
||||
try {
|
||||
appendPromptToChat(prompt);
|
||||
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SCROLL_CHAT_TO_BOTTOM));
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Prompt sent to chat successfully',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../../constants/events';
|
||||
/**
|
||||
* MCP Apps Renderer
|
||||
*
|
||||
@@ -142,7 +143,7 @@ export default function McpAppRenderer({
|
||||
// MCP Apps can send other content block types, but we only append text blocks for now
|
||||
|
||||
append(textContent.text);
|
||||
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SCROLL_CHAT_TO_BOTTOM));
|
||||
return {} satisfies McpMethodResponse['ui/message'];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
import BaseChat from './BaseChat';
|
||||
|
||||
export interface PairRouteState {
|
||||
resumeSessionId?: string;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
interface PairProps {
|
||||
setChat: (chat: ChatType) => void;
|
||||
sessionId: string;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
export default function Pair({ setChat, sessionId, initialMessage }: PairProps) {
|
||||
return (
|
||||
<BaseChat
|
||||
setChat={setChat}
|
||||
sessionId={sessionId}
|
||||
initialMessage={initialMessage}
|
||||
suppressEmptyState={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface SessionIndicatorsProps {
|
||||
isStreaming: boolean;
|
||||
hasUnread: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicators for session status (priority order: error > streaming > unread)
|
||||
*/
|
||||
export const SessionIndicators = React.memo<SessionIndicatorsProps>(
|
||||
({ isStreaming, hasUnread, hasError }) => {
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle
|
||||
className="w-3.5 h-3.5 text-red-500"
|
||||
aria-label="Session encountered an error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 text-blue-500 animate-spin" aria-label="Streaming" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasUnread) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" aria-label="Has new activity" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
SessionIndicators.displayName = 'SessionIndicators';
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../constants/events';
|
||||
import { ToolIconWithStatus, ToolCallStatus } from './ToolCallStatusIndicator';
|
||||
import { getToolCallIcon } from '../utils/toolIconMapping';
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
@@ -348,11 +349,11 @@ function ToolCallView({
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
window.addEventListener('responseStyleChanged', handleStorageChange);
|
||||
window.addEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('responseStyleChanged', handleStorageChange);
|
||||
window.removeEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [hasBeenEdited, setHasBeenEdited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Extract text content from the message
|
||||
@@ -100,7 +99,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
|
||||
if (onMessageUpdate && message.id) {
|
||||
onMessageUpdate(message.id, editContent, editType);
|
||||
setHasBeenEdited(true);
|
||||
}
|
||||
},
|
||||
[editContent, displayText, onMessageUpdate, message.id]
|
||||
@@ -255,13 +253,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edited indicator */}
|
||||
{hasBeenEdited && !isEditing && (
|
||||
<div className="text-xs text-text-subtle mt-1 text-right transition-opacity duration-200">
|
||||
Edited
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { FaCircle } from 'react-icons/fa';
|
||||
import { isEqual } from 'lodash';
|
||||
@@ -179,9 +180,9 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hide-alert-popover', handleHidePopover);
|
||||
window.addEventListener(AppEvents.HIDE_ALERT_POPOVER, handleHidePopover);
|
||||
return () => {
|
||||
window.removeEventListener('hide-alert-popover', handleHidePopover);
|
||||
window.removeEventListener(AppEvents.HIDE_ALERT_POPOVER, handleHidePopover);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
|
||||
@@ -38,12 +39,12 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
}, 500);
|
||||
};
|
||||
|
||||
window.addEventListener('session-created', handleSessionLoaded);
|
||||
window.addEventListener('message-stream-finished', handleSessionLoaded);
|
||||
window.addEventListener(AppEvents.SESSION_CREATED, handleSessionLoaded);
|
||||
window.addEventListener(AppEvents.MESSAGE_STREAM_FINISHED, handleSessionLoaded);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('session-created', handleSessionLoaded);
|
||||
window.removeEventListener('message-stream-finished', handleSessionLoaded);
|
||||
window.removeEventListener(AppEvents.SESSION_CREATED, handleSessionLoaded);
|
||||
window.removeEventListener(AppEvents.MESSAGE_STREAM_FINISHED, handleSessionLoaded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate } from '../../utils/date';
|
||||
import { Session } from '../../api';
|
||||
import { shouldShowNewChatTitle } from '../../sessions';
|
||||
import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext';
|
||||
|
||||
interface SessionItemProps {
|
||||
session: Session;
|
||||
@@ -9,10 +11,12 @@ interface SessionItemProps {
|
||||
}
|
||||
|
||||
const SessionItem: React.FC<SessionItemProps> = ({ session, extraActions }) => {
|
||||
const displayName = shouldShowNewChatTitle(session) ? DEFAULT_CHAT_TITLE : session.name;
|
||||
|
||||
return (
|
||||
<Card className="p-4 mb-2 hover:bg-accent/50 cursor-pointer flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium">{session.name}</div>
|
||||
<div className="font-medium">{displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(session.updated_at)} • {session.message_count} messages
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo, startTransition } from 'react';
|
||||
import {
|
||||
MessageSquareText,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
} from '../../api';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
|
||||
import { shouldShowNewChatTitle } from '../../sessions';
|
||||
import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext';
|
||||
|
||||
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
|
||||
try {
|
||||
@@ -416,6 +419,11 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
setSessions((prevSessions) =>
|
||||
prevSessions.map((s) => (s.id === sessionId ? { ...s, name: newDescription } : s))
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_RENAMED, {
|
||||
detail: { sessionId, newName: newDescription },
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleEditSession = useCallback((session: Session) => {
|
||||
@@ -442,6 +450,9 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
throwOnError: true,
|
||||
});
|
||||
toast.success('Session deleted successfully');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } })
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -563,6 +574,8 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
[onOpenInNewWindow, session]
|
||||
);
|
||||
|
||||
const displayName = shouldShowNewChatTitle(session) ? DEFAULT_CHAT_TITLE : session.name;
|
||||
|
||||
// Get extension names for this session
|
||||
const extensionNames = useMemo(
|
||||
() => getSessionExtensionNames(session.extension_data),
|
||||
@@ -576,7 +589,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
ref={(el) => setSessionRefs(session.id, el)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 className="text-base break-words line-clamp-2 flex-1 min-w-0">{session.name}</h3>
|
||||
<h3 className="text-base break-words line-clamp-2 flex-1 min-w-0">{displayName}</h3>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<button
|
||||
onClick={handleOpenInNewWindowClick}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEvents } from '../../../constants/events';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { all_response_styles, ResponseStyleSelectionItem } from './ResponseStyleSelectionItem';
|
||||
|
||||
@@ -24,7 +25,7 @@ export const ResponseStylesSection = () => {
|
||||
localStorage.setItem('response_style', newStyle);
|
||||
|
||||
// Dispatch custom event to notify other components of the change
|
||||
window.dispatchEvent(new CustomEvent('responseStyleChanged'));
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.RESPONSE_STYLE_CHANGED));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -221,7 +221,7 @@ function Sidebar({
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:translate-x-[100%]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'py-2 pl-2 pr-4 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
? 'py-2 pl-2 pr-0 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
)}
|
||||
@@ -359,7 +359,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden',
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden sidebar-scrollbar',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -372,7 +372,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col px-2', className)}
|
||||
className={cn('relative flex w-full min-w-0 flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user