Multi chat (#6428)

This commit is contained in:
Zane
2026-01-21 07:13:55 -10:00
committed by GitHub
parent f26ffed587
commit a7699e1607
34 changed files with 1673 additions and 644 deletions
+76 -69
View File
@@ -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}
+168 -162
View File
@@ -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()
);
}}
>
+8
View File
@@ -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,
+6 -2
View File
@@ -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);
}
};
+33 -6
View File
@@ -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'];
}
-26
View File
@@ -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 (
+3 -3
View File
@@ -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}
/>
);