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
+1 -3
View File
@@ -34,7 +34,6 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
@@ -209,8 +208,7 @@ async fn start_agent(
}
}
let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1;
let name = format!("New session {}", counter);
let name = "New Chat".to_string();
let manager = state.session_manager();
-3
View File
@@ -4,7 +4,6 @@ use goose::scheduler_trait::SchedulerTrait;
use goose::session::SessionManager;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
@@ -19,7 +18,6 @@ type ExtensionLoadingTasks =
pub struct AppState {
pub(crate) agent_manager: Arc<AgentManager>,
pub recipe_file_hash_map: Arc<Mutex<HashMap<String, PathBuf>>>,
pub session_counter: Arc<AtomicUsize>,
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
recipe_session_tracker: Arc<Mutex<HashSet<String>>>,
pub tunnel_manager: Arc<TunnelManager>,
@@ -34,7 +32,6 @@ impl AppState {
Ok(Arc::new(Self {
agent_manager,
recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())),
session_counter: Arc::new(AtomicUsize::new(0)),
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
tunnel_manager,
extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
+183 -123
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { IpcRendererEvent } from 'electron';
import {
HashRouter,
@@ -20,14 +20,18 @@ import { createSession } from './sessions';
import { ChatType } from './types/chat';
import Hub from './components/Hub';
import Pair, { PairRouteState } from './components/Pair';
interface PairRouteState {
resumeSessionId?: string;
initialMessage?: string;
}
import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView';
import SchedulesView from './components/schedule/SchedulesView';
import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
import { AppLayout } from './components/Layout/AppLayout';
import { ChatProvider } from './contexts/ChatContext';
import { ChatProvider, DEFAULT_CHAT_TITLE } from './contexts/ChatContext';
import LauncherView from './components/LauncherView';
import 'react-toastify/dist/ReactToastify.css';
@@ -47,6 +51,7 @@ import { errorMessage } from './utils/conversionUtils';
import { getInitialWorkingDir } from './utils/workingDir';
import { usePageViewTracking } from './hooks/useAnalytics';
import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics';
import { AppEvents } from './constants/events';
function PageViewTracker() {
usePageViewTracking();
@@ -60,53 +65,28 @@ const HubRouteWrapper = () => {
};
const PairRouteWrapper = ({
chat,
setChat,
activeSessions,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
activeSessions: Array<{ sessionId: string; initialMessage?: string }>;
setActiveSessions: (sessions: Array<{ sessionId: string; initialMessage?: string }>) => void;
}) => {
const { extensionsList } = useConfig();
const location = useLocation();
const navigate = useNavigate();
const routeState = (location.state as PairRouteState) || {};
const [searchParams] = useSearchParams();
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const [searchParams, setSearchParams] = useSearchParams();
const [isCreatingSession, setIsCreatingSession] = useState(false);
// Capture initialMessage in local state to survive route state being cleared
const [capturedInitialMessage, setCapturedInitialMessage] = useState<string | undefined>(
undefined
);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
// Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink)
const sessionIdFromState = routeState.resumeSessionId;
const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined;
// Use route state if available, otherwise use captured state
const initialMessage = routeState.initialMessage || capturedInitialMessage;
// Capture initialMessage when it comes from route state
useEffect(() => {
console.log(
'[PairRouteWrapper] capture effect:',
JSON.stringify({
routeStateInitialMessage: routeState.initialMessage,
})
);
if (routeState.initialMessage) {
setCapturedInitialMessage(routeState.initialMessage);
}
}, [routeState.initialMessage]);
const initialMessage = routeState.initialMessage;
// Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId
useEffect(() => {
if (
(initialMessage || recipeId || recipeDeeplinkFromConfig) &&
!sessionId &&
!resumeSessionId &&
!isCreatingSession
) {
setIsCreatingSession(true);
@@ -118,9 +98,20 @@ const PairRouteWrapper = ({
recipeDeeplink: recipeDeeplinkFromConfig,
allExtensions: extensionsList,
});
navigate(`/pair?resumeSessionId=${newSession.id}`, {
replace: true,
state: { resumeSessionId: newSession.id, initialMessage },
window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: {
sessionId: newSession.id,
initialMessage,
},
})
);
setSearchParams((prev) => {
prev.set('resumeSessionId', newSession.id);
prev.delete('recipeId');
return prev;
});
} catch (error) {
console.error('Failed to create session:', error);
@@ -134,45 +125,33 @@ const PairRouteWrapper = ({
}
})();
}
// Note: isCreatingSession is intentionally NOT in the dependency array
// It's only used as a guard to prevent concurrent session creation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialMessage,
recipeId,
recipeDeeplinkFromConfig,
sessionId,
isCreatingSession,
resumeSessionId,
setSearchParams,
extensionsList,
navigate,
]);
// Sync URL with session ID for refresh support (only if not already in URL)
// Add resumed session to active sessions if not already there
useEffect(() => {
if (sessionId && sessionId !== resumeSessionId) {
navigate(`/pair?resumeSessionId=${sessionId}`, {
replace: true,
state: { resumeSessionId: sessionIdFromState, initialMessage },
});
if (resumeSessionId && !activeSessions.some((s) => s.sessionId === resumeSessionId)) {
window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: {
sessionId: resumeSessionId,
initialMessage: initialMessage,
},
})
);
}
}, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]);
}, [resumeSessionId, activeSessions, initialMessage]);
// Clear captured initialMessage when session changes (to prevent re-sending on navigation)
useEffect(() => {
if (sessionId && capturedInitialMessage && sessionIdFromState) {
const timer = setTimeout(() => {
setCapturedInitialMessage(undefined);
}, 100);
return () => clearTimeout(timer);
}
return undefined;
}, [sessionId, capturedInitialMessage, sessionIdFromState]);
return (
<Pair
key={sessionId}
setChat={setChat}
sessionId={sessionId ?? ''}
initialMessage={initialMessage}
/>
);
return null;
};
const SettingsRoute = () => {
@@ -366,11 +345,63 @@ export function AppInner() {
const [chat, setChat] = useState<ChatType>({
sessionId: '',
name: 'Pair Chat',
name: DEFAULT_CHAT_TITLE,
messages: [],
recipe: null,
});
const MAX_ACTIVE_SESSIONS = 10;
const [activeSessions, setActiveSessions] = useState<
Array<{ sessionId: string; initialMessage?: string }>
>([]);
useEffect(() => {
const handleAddActiveSession = (event: Event) => {
const { sessionId, initialMessage } = (
event as CustomEvent<{ sessionId: string; initialMessage?: string }>
).detail;
setActiveSessions((prev) => {
const existingIndex = prev.findIndex((s) => s.sessionId === sessionId);
if (existingIndex !== -1) {
// Session exists - move to end of LRU list (most recently used)
const existing = prev[existingIndex];
return [...prev.slice(0, existingIndex), ...prev.slice(existingIndex + 1), existing];
}
// New session - add to end with LRU eviction if needed
const newSession = { sessionId, initialMessage };
const updated = [...prev, newSession];
if (updated.length > MAX_ACTIVE_SESSIONS) {
return updated.slice(updated.length - MAX_ACTIVE_SESSIONS);
}
return updated;
});
};
const handleClearInitialMessage = (event: Event) => {
const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail;
setActiveSessions((prev) => {
return prev.map((session) => {
if (session.sessionId === sessionId) {
return { ...session, initialMessage: undefined };
}
return session;
});
});
};
window.addEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession);
window.addEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage);
return () => {
window.removeEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession);
window.removeEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage);
};
}, []);
const { addExtension } = useConfig();
useEffect(() => {
@@ -515,6 +546,16 @@ export function AppInner() {
return () => window.electron.off('set-view', handleSetView);
}, [navigate]);
useEffect(() => {
const handleNewChat = (_event: IpcRendererEvent, ..._args: unknown[]) => {
console.log('Received new-chat event from keyboard shortcut');
window.dispatchEvent(new CustomEvent(AppEvents.TRIGGER_NEW_CHAT));
};
window.electron.on('new-chat', handleNewChat);
return () => window.electron.off('new-chat', handleNewChat);
}, []);
useEffect(() => {
const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => {
const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement;
@@ -529,22 +570,31 @@ export function AppInner() {
}, []);
// Handle initial message from launcher
const isProcessingRef = useRef(false);
useEffect(() => {
const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const initialMessage = args[0] as string;
if (initialMessage) {
console.log('Received initial message from launcher:', initialMessage);
try {
const session = await createSession(getInitialWorkingDir(), {});
navigate('/pair', {
state: {
initialMessage,
resumeSessionId: session.id,
},
});
} catch (error) {
console.error('Failed to create session for launcher message:', error);
}
console.log(
'[App] Received set-initial-message event:',
initialMessage,
'isProcessing:',
isProcessingRef.current
);
if (initialMessage && !isProcessingRef.current) {
isProcessingRef.current = true;
console.log('[App] Processing initial message from launcher:', initialMessage);
navigate('/pair', {
state: {
initialMessage,
},
});
setTimeout(() => {
isProcessingRef.current = false;
}, 1000);
} else if (initialMessage) {
console.log('[App] Ignoring duplicate initial message (already processing)');
}
};
window.electron.on('set-initial-message', handleSetInitialMessage);
@@ -578,52 +628,62 @@ export function AppInner() {
<ExtensionInstallModal addExtension={addExtension} setView={setView} />
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<Routes>
<Route path="launcher" element={<LauncherView />} />
<Route
path="welcome"
element={<WelcomeRoute onSelectProvider={() => setDidSelectProvider(true)} />}
/>
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
<Route path="standalone-app" element={<StandaloneAppView />} />
<Route
path="/"
element={
<ProviderGuard didSelectProvider={didSelectProvider}>
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
<AppLayout />
</ChatProvider>
</ProviderGuard>
}
>
<Route index element={<HubRouteWrapper />} />
<Route path="pair" element={<PairRouteWrapper chat={chat} setChat={setChat} />} />
<Route path="settings" element={<SettingsRoute />} />
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Routes>
<Route path="launcher" element={<LauncherView />} />
<Route
path="extensions"
element={
<ChatProvider chat={chat} setChat={setChat} contextKey="extensions">
<ExtensionsRoute />
</ChatProvider>
}
path="welcome"
element={<WelcomeRoute onSelectProvider={() => setDidSelectProvider(true)} />}
/>
<Route path="apps" element={<AppsView />} />
<Route path="sessions" element={<SessionsRoute />} />
<Route path="schedules" element={<SchedulesRoute />} />
<Route path="recipes" element={<RecipesRoute />} />
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
<Route path="standalone-app" element={<StandaloneAppView />} />
<Route
path="shared-session"
path="/"
element={
<SharedSessionRouteWrapper
isLoadingSharedSession={isLoadingSharedSession}
setIsLoadingSharedSession={setIsLoadingSharedSession}
sharedSessionError={sharedSessionError}
/>
<ProviderGuard didSelectProvider={didSelectProvider}>
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
<AppLayout activeSessions={activeSessions} />
</ChatProvider>
</ProviderGuard>
}
/>
<Route path="permission" element={<PermissionRoute />} />
</Route>
</Routes>
>
<Route index element={<HubRouteWrapper />} />
<Route
path="pair"
element={
<PairRouteWrapper
activeSessions={activeSessions}
setActiveSessions={setActiveSessions}
/>
}
/>
<Route path="settings" element={<SettingsRoute />} />
<Route
path="extensions"
element={
<ChatProvider chat={chat} setChat={setChat} contextKey="extensions">
<ExtensionsRoute />
</ChatProvider>
}
/>
<Route path="apps" element={<AppsView />} />
<Route path="sessions" element={<SessionsRoute />} />
<Route path="schedules" element={<SchedulesRoute />} />
<Route path="recipes" element={<RecipesRoute />} />
<Route
path="shared-session"
element={
<SharedSessionRouteWrapper
isLoadingSharedSession={isLoadingSharedSession}
setIsLoadingSharedSession={setIsLoadingSharedSession}
sharedSessionError={sharedSessionError}
/>
}
/>
<Route path="permission" element={<PermissionRoute />} />
</Route>
</Routes>
</div>
</div>
</>
);
+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}
/>
);
+18
View File
@@ -0,0 +1,18 @@
/**
* Custom event names used throughout the application.
*/
export enum AppEvents {
SESSION_CREATED = 'session-created',
SESSION_DELETED = 'session-deleted',
SESSION_RENAMED = 'session-renamed',
SESSION_FORKED = 'session-forked',
SESSION_NEEDS_NAME_UPDATE = 'session-needs-name-update',
SESSION_STATUS_UPDATE = 'session-status-update',
ADD_ACTIVE_SESSION = 'add-active-session',
CLEAR_INITIAL_MESSAGE = 'clear-initial-message',
TRIGGER_NEW_CHAT = 'trigger-new-chat',
MESSAGE_STREAM_FINISHED = 'message-stream-finished',
SCROLL_CHAT_TO_BOTTOM = 'scroll-chat-to-bottom',
HIDE_ALERT_POPOVER = 'hide-alert-popover',
RESPONSE_STYLE_CHANGED = 'responseStyleChanged',
}
+103
View File
@@ -0,0 +1,103 @@
import { AppEvents } from '../constants/events';
import { useCallback, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Session } from '../api';
import { Message } from '../api';
import { ChatState } from '../types/chatState';
/**
* Auto-submit scenarios:
* 1. New session with initial message from Hub (message_count === 0, has initialMessage)
* 2. Forked session with edited message (shouldStartAgent + initialMessage)
* 3. Resume with shouldStartAgent (continue existing conversation)
*/
interface UseAutoSubmitProps {
sessionId: string;
session: Session | undefined;
messages: Message[];
chatState: ChatState;
initialMessage: string | undefined;
handleSubmit: (message: string) => void;
}
interface UseAutoSubmitReturn {
hasAutoSubmitted: boolean;
}
export function useAutoSubmit({
sessionId,
session,
messages,
chatState,
initialMessage,
handleSubmit,
}: UseAutoSubmitProps): UseAutoSubmitReturn {
const [searchParams] = useSearchParams();
const hasAutoSubmittedRef = useRef(false);
// Reset auto-submit flag when session changes
useEffect(() => {
hasAutoSubmittedRef.current = false;
}, [sessionId]);
const clearInitialMessage = useCallback(() => {
window.dispatchEvent(
new CustomEvent(AppEvents.CLEAR_INITIAL_MESSAGE, {
detail: { sessionId },
})
);
}, [sessionId]);
// Auto-submit logic
useEffect(() => {
const currentSessionId = searchParams.get('resumeSessionId');
const isCurrentSession = currentSessionId === sessionId;
const shouldStartAgent = isCurrentSession && searchParams.get('shouldStartAgent') === 'true';
if (!session || hasAutoSubmittedRef.current) {
return;
}
// Don't submit if already streaming or loading
if (chatState !== ChatState.Idle) {
return;
}
// Scenario 1: New session with initial message from Hub
// Hub always creates new sessions, so message_count will be 0
if (initialMessage && session.message_count === 0 && messages.length === 0) {
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
clearInitialMessage();
return;
}
// Scenario 2: Forked session with edited message
if (shouldStartAgent && initialMessage) {
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
clearInitialMessage();
return;
}
// Scenario 3: Resume with shouldStartAgent (continue existing conversation)
if (shouldStartAgent) {
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
}, [
session,
initialMessage,
searchParams,
handleSubmit,
sessionId,
messages.length,
chatState,
clearInitialMessage,
]);
return {
hasAutoSubmitted: hasAutoSubmittedRef.current,
};
}
+301 -130
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { AppEvents } from '../constants/events';
import { ChatState } from '../types/chatState';
import {
@@ -54,6 +55,122 @@ interface UseChatStreamReturn {
) => Promise<void>;
}
interface StreamState {
messages: Message[];
session: Session | undefined;
chatState: ChatState;
sessionLoadError: string | undefined;
tokenState: TokenState;
notifications: NotificationEvent[];
}
type StreamAction =
| { type: 'SET_MESSAGES'; payload: Message[] }
| { type: 'SET_SESSION'; payload: Session | undefined }
| { type: 'SET_CHAT_STATE'; payload: ChatState }
| { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined }
| { type: 'SET_TOKEN_STATE'; payload: TokenState }
| { type: 'ADD_NOTIFICATION'; payload: NotificationEvent }
| { type: 'CLEAR_NOTIFICATIONS' }
| {
type: 'SESSION_LOADED';
payload: {
session: Session;
messages: Message[];
tokenState: TokenState;
};
}
| { type: 'RESET_FOR_NEW_SESSION' }
| { type: 'START_STREAMING' }
| { type: 'STREAM_ERROR'; payload: string }
| { type: 'STREAM_FINISH'; payload?: string };
const initialTokenState: TokenState = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
};
const initialState: StreamState = {
messages: [],
session: undefined,
chatState: ChatState.Idle,
sessionLoadError: undefined,
tokenState: initialTokenState,
notifications: [],
};
function streamReducer(state: StreamState, action: StreamAction): StreamState {
switch (action.type) {
case 'SET_MESSAGES':
return { ...state, messages: action.payload };
case 'SET_SESSION':
return { ...state, session: action.payload };
case 'SET_CHAT_STATE':
return { ...state, chatState: action.payload };
case 'SET_SESSION_LOAD_ERROR':
return { ...state, sessionLoadError: action.payload };
case 'SET_TOKEN_STATE':
return { ...state, tokenState: action.payload };
case 'ADD_NOTIFICATION':
return { ...state, notifications: [...state.notifications, action.payload] };
case 'CLEAR_NOTIFICATIONS':
return { ...state, notifications: [] };
case 'SESSION_LOADED':
return {
...state,
session: action.payload.session,
messages: action.payload.messages,
tokenState: action.payload.tokenState,
chatState: ChatState.Idle,
sessionLoadError: undefined,
};
case 'RESET_FOR_NEW_SESSION':
return {
...state,
messages: [],
session: undefined,
sessionLoadError: undefined,
chatState: ChatState.LoadingConversation,
};
case 'START_STREAMING':
return {
...state,
chatState: ChatState.Streaming,
notifications: [],
};
case 'STREAM_ERROR':
return {
...state,
sessionLoadError: action.payload,
chatState: ChatState.Idle,
};
case 'STREAM_FINISH':
return {
...state,
sessionLoadError: action.payload,
chatState: ChatState.Idle,
};
default:
return state;
}
}
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
const lastMsg = currentMessages[currentMessages.length - 1];
@@ -79,10 +196,7 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
async function streamFromResponse(
stream: AsyncIterable<MessageEvent>,
initialMessages: Message[],
updateMessages: (messages: Message[]) => void,
updateTokenState: (tokenState: TokenState) => void,
updateChatState: (state: ChatState) => void,
updateNotifications: (notification: NotificationEvent) => void,
dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void
): Promise<void> {
let currentMessages = initialMessages;
@@ -104,17 +218,17 @@ async function streamFromResponse(
);
if (hasToolConfirmation || hasElicitation) {
updateChatState(ChatState.WaitingForUserInput);
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput });
} else if (getCompactingMessage(msg)) {
updateChatState(ChatState.Compacting);
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Compacting });
} else if (getThinkingMessage(msg)) {
updateChatState(ChatState.Thinking);
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking });
} else {
updateChatState(ChatState.Streaming);
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
}
updateTokenState(event.token_state);
updateMessages(currentMessages);
dispatch({ type: 'SET_TOKEN_STATE', payload: event.token_state });
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
break;
}
case 'Error': {
@@ -129,14 +243,12 @@ async function streamFromResponse(
break;
}
case 'UpdateConversation': {
// WARNING: Since Message handler uses this local variable, we need to update it here to avoid the client clobbering it.
// Longterm fix is to only send the agent the new messages, not the entire conversation.
currentMessages = event.conversation;
updateMessages(event.conversation);
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
break;
}
case 'Notification': {
updateNotifications(event as NotificationEvent);
dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent });
break;
}
case 'Ping':
@@ -157,44 +269,41 @@ export function useChatStream({
onStreamFinish,
onSessionLoaded,
}: UseChatStreamProps): UseChatStreamReturn {
const [messages, setMessages] = useState<Message[]>([]);
const messagesRef = useRef<Message[]>([]);
const [session, setSession] = useState<Session>();
const [sessionLoadError, setSessionLoadError] = useState<string>();
const [chatState, setChatState] = useState<ChatState>(ChatState.Idle);
const [tokenState, setTokenState] = useState<TokenState>({
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
});
const [notifications, setNotifications] = useState<NotificationEvent[]>([]);
const [state, dispatch] = useReducer(streamReducer, initialState);
// Refs for values needed in callbacks without causing re-renders
const abortControllerRef = useRef<AbortController | null>(null);
const lastInteractionTimeRef = useRef<number>(Date.now());
const namePollingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref to access latest state in callbacks (avoids stale closures)
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
if (session) {
resultsCache.set(sessionId, { session, messages });
return () => {
if (namePollingRef.current) {
clearTimeout(namePollingRef.current);
namePollingRef.current = null;
}
};
}, [sessionId]);
useEffect(() => {
if (state.session) {
resultsCache.set(sessionId, { session: state.session, messages: state.messages });
}
}, [sessionId, session, messages]);
const updateMessages = useCallback((newMessages: Message[]) => {
setMessages(newMessages);
messagesRef.current = newMessages;
}, []);
const updateNotifications = useCallback((notification: NotificationEvent) => {
setNotifications((prev) => [...prev, notification]);
}, []);
}, [sessionId, state.session, state.messages]);
const onFinish = useCallback(
async (error?: string): Promise<void> => {
if (error) {
setSessionLoadError(error);
if (namePollingRef.current) {
clearTimeout(namePollingRef.current);
namePollingRef.current = null;
}
dispatch({ type: 'STREAM_FINISH', payload: error });
const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current;
if (!error && timeSinceLastInteraction > 60000) {
window.electron.showNotification({
@@ -208,14 +317,15 @@ export function useChatStream({
console.log(
'useChatStream: Message stream finished for new session, emitting message-stream-finished event'
);
window.dispatchEvent(new CustomEvent('message-stream-finished'));
window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED));
}
// Refresh session name after each reply for the first 3 user messages
// The backend regenerates the name after each of the first 3 user messages
// to refine it as more context becomes available
if (!error && sessionId) {
const userMessageCount = messagesRef.current.filter((m) => m.role === 'user').length;
const currentState = stateRef.current;
const userMessageCount = currentState.messages.filter((m) => m.role === 'user').length;
// Only refresh for the first 3 user messages
if (userMessageCount <= 3) {
@@ -225,7 +335,18 @@ export function useChatStream({
throwOnError: true,
});
if (response.data?.name) {
setSession((prev) => (prev ? { ...prev, name: response.data.name } : prev));
dispatch({
type: 'SET_SESSION',
payload: currentState.session
? { ...currentState.session, name: response.data.name }
: undefined,
});
// Notify sidebar of the name change
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_RENAMED, {
detail: { sessionId, newName: response.data.name },
})
);
}
} catch (refreshError) {
// Silently fail - this is a nice-to-have feature
@@ -234,7 +355,6 @@ export function useChatStream({
}
}
setChatState(ChatState.Idle);
onStreamFinish();
},
[onStreamFinish, sessionId]
@@ -246,26 +366,26 @@ export function useChatStream({
const cached = resultsCache.get(sessionId);
if (cached) {
setSession(cached.session);
updateMessages(cached.messages);
setTokenState({
inputTokens: cached.session?.input_tokens ?? 0,
outputTokens: cached.session?.output_tokens ?? 0,
totalTokens: cached.session?.total_tokens ?? 0,
accumulatedInputTokens: cached.session?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: cached.session?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0,
dispatch({
type: 'SESSION_LOADED',
payload: {
session: cached.session,
messages: cached.messages,
tokenState: {
inputTokens: cached.session?.input_tokens ?? 0,
outputTokens: cached.session?.output_tokens ?? 0,
totalTokens: cached.session?.total_tokens ?? 0,
accumulatedInputTokens: cached.session?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: cached.session?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0,
},
},
});
setChatState(ChatState.Idle);
onSessionLoaded?.();
return;
}
// Reset state when sessionId changes
updateMessages([]);
setSession(undefined);
setSessionLoadError(undefined);
setChatState(ChatState.LoadingConversation);
dispatch({ type: 'RESET_FOR_NEW_SESSION' });
let cancelled = false;
@@ -288,17 +408,22 @@ export function useChatStream({
const extensionResults = resumeData?.extension_results;
showExtensionLoadResults(extensionResults);
setSession(loadedSession);
updateMessages(loadedSession?.conversation || []);
setTokenState({
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
dispatch({
type: 'SESSION_LOADED',
payload: {
session: loadedSession!,
messages: loadedSession?.conversation || [],
tokenState: {
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
},
},
});
setChatState(ChatState.Idle);
listApps({
throwOnError: true,
@@ -311,24 +436,25 @@ export function useChatStream({
} catch (error) {
if (cancelled) return;
setSessionLoadError(errorMessage(error));
setChatState(ChatState.Idle);
dispatch({ type: 'STREAM_ERROR', payload: errorMessage(error) });
}
})();
return () => {
cancelled = true;
};
}, [sessionId, updateMessages, onSessionLoaded]);
}, [sessionId, onSessionLoaded]);
const handleSubmit = useCallback(
async (userMessage: string) => {
const currentState = stateRef.current;
// Guard: Don't submit if session hasn't been loaded yet
if (!session || chatState === ChatState.LoadingConversation) {
if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) {
return;
}
const hasExistingMessages = messagesRef.current.length > 0;
const hasExistingMessages = currentState.messages.length > 0;
const hasNewMessage = userMessage.trim().length > 0;
// Don't submit if there's no message and no conversation to continue
@@ -340,22 +466,68 @@ export function useChatStream({
// Emit session-created event for first message in a new session
if (!hasExistingMessages && hasNewMessage) {
window.dispatchEvent(new CustomEvent('session-created'));
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
// Start polling for session name update during streaming
// The backend generates the name in parallel with the response
const pollForName = async (attempts = 0) => {
if (attempts >= 20) return; // Max 20 attempts (10 seconds)
try {
const response = await getSession({
path: { session_id: sessionId },
throwOnError: true,
});
const currentState = stateRef.current;
const currentName = currentState.session?.name;
const newName = response.data?.name;
// Check if name has changed from the initial name
if (newName && newName !== currentName) {
dispatch({
type: 'SET_SESSION',
payload: currentState.session
? { ...currentState.session, name: newName }
: undefined,
});
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_RENAMED, {
detail: { sessionId, newName },
})
);
return; // Stop polling once name is updated
}
} catch {
// Silently continue polling
}
// Continue polling if still streaming
const latestState = stateRef.current;
if (
latestState.chatState === ChatState.Streaming ||
latestState.chatState === ChatState.Thinking ||
latestState.chatState === ChatState.Compacting
) {
namePollingRef.current = setTimeout(() => pollForName(attempts + 1), 500);
}
};
// Start polling after a short delay to give backend time to start name generation
namePollingRef.current = setTimeout(() => pollForName(0), 1000);
}
const newMessage = hasNewMessage
? createUserMessage(userMessage)
: messagesRef.current[messagesRef.current.length - 1];
: currentState.messages[currentState.messages.length - 1];
const currentMessages = hasNewMessage
? [...messagesRef.current, newMessage]
: [...messagesRef.current];
? [...currentState.messages, newMessage]
: [...currentState.messages];
if (hasNewMessage) {
updateMessages(currentMessages);
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
}
setChatState(ChatState.Streaming);
setNotifications([]);
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
@@ -368,15 +540,7 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
await streamFromResponse(
stream,
currentMessages,
updateMessages,
setTokenState,
setChatState,
updateNotifications,
onFinish
);
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
} catch (error) {
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
@@ -387,23 +551,24 @@ export function useChatStream({
}
}
},
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
[sessionId, onFinish]
);
const submitElicitationResponse = useCallback(
async (elicitationId: string, userData: Record<string, unknown>) => {
if (!session || chatState === ChatState.LoadingConversation) {
const currentState = stateRef.current;
if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) {
return;
}
lastInteractionTimeRef.current = Date.now();
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
const currentMessages = [...messagesRef.current, responseMessage];
const currentMessages = [...currentState.messages, responseMessage];
updateMessages(currentMessages);
setChatState(ChatState.Streaming);
setNotifications([]);
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
@@ -416,15 +581,7 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
await streamFromResponse(
stream,
currentMessages,
updateMessages,
setTokenState,
setChatState,
updateNotifications,
onFinish
);
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
@@ -433,12 +590,14 @@ export function useChatStream({
}
}
},
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
[sessionId, onFinish]
);
const setRecipeUserParams = useCallback(
async (user_recipe_values: Record<string, string>) => {
if (session) {
const currentState = stateRef.current;
if (currentState.session) {
await updateSessionUserRecipeValues({
path: {
session_id: sessionId,
@@ -449,42 +608,50 @@ export function useChatStream({
throwOnError: true,
});
// TODO(Douwe): get this from the server instead of emulating it here
setSession({
...session,
user_recipe_values,
dispatch({
type: 'SET_SESSION',
payload: {
...currentState.session,
user_recipe_values,
},
});
} else {
setSessionLoadError("can't call setRecipeParams without a session");
dispatch({
type: 'SET_SESSION_LOAD_ERROR',
payload: "can't call setRecipeParams without a session",
});
}
},
[sessionId, session, setSessionLoadError]
[sessionId]
);
useEffect(() => {
// This should happen on the server when the session is loaded or changed
// use session.id to support changing of sessions rather than depending on the
// stable sessionId.
if (session) {
if (state.session) {
updateFromSession({
body: {
session_id: session.id,
session_id: state.session.id,
},
throwOnError: true,
});
}
}, [session]);
}, [state.session]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
setChatState(ChatState.Idle);
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
lastInteractionTimeRef.current = Date.now();
}, []);
const onMessageUpdate = useCallback(
async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => {
const currentState = stateRef.current;
try {
const { editMessage } = await import('../api');
const message = messagesRef.current.find((m) => m.id === messageId);
const message = currentState.messages.find((m) => m.id === messageId);
if (!message) {
throw new Error(`Message with id ${messageId} not found in current messages`);
@@ -507,7 +674,7 @@ export function useChatStream({
}
if (editType === 'fork') {
const event = new CustomEvent('session-forked', {
const event = new CustomEvent(AppEvents.SESSION_FORKED, {
detail: {
newSessionId: targetSessionId,
shouldStartAgent: true,
@@ -524,7 +691,7 @@ export function useChatStream({
});
if (sessionResponse.data?.conversation) {
updateMessages(sessionResponse.data.conversation);
dispatch({ type: 'SET_MESSAGES', payload: sessionResponse.data.conversation });
}
await handleSubmit(newContent);
}
@@ -538,15 +705,19 @@ export function useChatStream({
});
}
},
[sessionId, handleSubmit, updateMessages]
[sessionId, handleSubmit]
);
const setChatState = useCallback((newState: ChatState) => {
dispatch({ type: 'SET_CHAT_STATE', payload: newState });
}, []);
const cached = resultsCache.get(sessionId);
const maybe_cached_messages = session ? messages : cached?.messages || [];
const maybe_cached_session = session ?? cached?.session;
const maybe_cached_messages = state.session ? state.messages : cached?.messages || [];
const maybe_cached_session = state.session ?? cached?.session;
const notificationsMap = useMemo(() => {
return notifications.reduce((map, notification) => {
return state.notifications.reduce((map, notification) => {
const key = notification.request_id;
if (!map.has(key)) {
map.set(key, []);
@@ -554,19 +725,19 @@ export function useChatStream({
map.get(key)!.push(notification);
return map;
}, new Map<string, NotificationEvent[]>());
}, [notifications]);
}, [state.notifications]);
return {
sessionLoadError,
sessionLoadError: state.sessionLoadError,
messages: maybe_cached_messages,
session: maybe_cached_session,
chatState,
chatState: state.chatState,
setChatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
setRecipeUserParams,
tokenState,
tokenState: state.tokenState,
notifications: notificationsMap,
onMessageUpdate,
};
@@ -0,0 +1,86 @@
import { AppEvents } from '../constants/events';
import { useState, useCallback, useRef, useEffect } from 'react';
type StreamState = 'idle' | 'streaming' | 'error';
interface SessionStatus {
streamState: StreamState;
hasUnreadActivity: boolean;
}
/**
* Simple hook to track session status for the sidebar.
* Listens to session-status-update events from BaseChat components.
*/
export function useSidebarSessionStatus(activeSessionId: string | undefined) {
const [statuses, setStatuses] = useState<Map<string, SessionStatus>>(new Map());
const activeSessionIdRef = useRef(activeSessionId);
// Keep ref in sync
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
// Clear unread when active session changes
useEffect(() => {
if (activeSessionId) {
setStatuses((prev) => {
const status = prev.get(activeSessionId);
if (status?.hasUnreadActivity) {
const next = new Map(prev);
next.set(activeSessionId, { ...status, hasUnreadActivity: false });
return next;
}
return prev;
});
}
}, [activeSessionId]);
// Listen for status updates from BaseChat
useEffect(() => {
const handleStatusUpdate = (event: Event) => {
const { sessionId, streamState } = (event as CustomEvent).detail;
setStatuses((prev) => {
const existing = prev.get(sessionId);
const wasStreaming = existing?.streamState === 'streaming';
const isNowIdle = streamState === 'idle';
const isBackground = sessionId !== activeSessionIdRef.current;
// Mark unread if streaming just finished in a background session
const shouldMarkUnread = isBackground && wasStreaming && isNowIdle;
const next = new Map(prev);
next.set(sessionId, {
streamState,
hasUnreadActivity: existing?.hasUnreadActivity || shouldMarkUnread,
});
return next;
});
};
window.addEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate);
return () => window.removeEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate);
}, []);
const getSessionStatus = useCallback(
(sessionId: string): SessionStatus | undefined => {
return statuses.get(sessionId);
},
[statuses]
);
const clearUnread = useCallback((sessionId: string) => {
setStatuses((prev) => {
const status = prev.get(sessionId);
if (status?.hasUnreadActivity) {
const next = new Map(prev);
next.set(sessionId, { ...status, hasUnreadActivity: false });
return next;
}
return prev;
});
}, []);
return { getSessionStatus, clearUnread };
}
+46 -18
View File
@@ -853,7 +853,14 @@ const createChat = async (
return mainWindow;
};
let activeLauncherWindow: BrowserWindow | null = null;
const createLauncher = () => {
if (activeLauncherWindow && !activeLauncherWindow.isDestroyed()) {
activeLauncherWindow.focus();
return activeLauncherWindow;
}
const launcherWindow = new BrowserWindow({
width: 600,
height: 80,
@@ -895,6 +902,11 @@ const createLauncher = () => {
url.hash = '/launcher';
launcherWindow.loadURL(formatUrl(url));
activeLauncherWindow = launcherWindow;
launcherWindow.on('closed', () => {
activeLauncherWindow = null;
});
// Destroy window when it loses focus
launcherWindow.on('blur', () => {
@@ -1953,13 +1965,11 @@ async function appMain() {
callback({ cancel: false, requestHeaders: details.requestHeaders });
});
// Create tray if enabled in settings
const settings = loadSettings();
if (settings.showMenuBarIcon) {
createTray();
}
// Handle dock icon visibility (macOS only)
if (process.platform === 'darwin' && !settings.showDockIcon && settings.showMenuBarIcon) {
app.dock?.hide();
}
@@ -1982,9 +1992,8 @@ async function appMain() {
log.error('Error setting up auto-updater:', error);
}
}
}, 2000); // 2 second delay after window is shown
}, 2000);
// Setup macOS dock menu
if (process.platform === 'darwin') {
const dockMenu = Menu.buildFromTemplate([
{
@@ -1997,13 +2006,10 @@ async function appMain() {
app.dock?.setMenu(dockMenu);
}
// Get the existing menu
const menu = Menu.getApplicationMenu();
// App menu
const appMenu = menu?.items.find((item) => item.label === 'Goose');
if (appMenu?.submenu) {
// add Settings to app menu after About
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
appMenu.submenu.insert(
1,
@@ -2019,13 +2025,10 @@ async function appMain() {
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
}
// Add Find submenu to Edit menu
const editMenu = menu?.items.find((item) => item.label === 'Edit');
if (editMenu?.submenu) {
// Find the index of Select All to insert after it
const selectAllIndex = editMenu.submenu.items.findIndex((item) => item.label === 'Select All');
// Create Find submenu
const findSubmenu = Menu.buildFromTemplate([
{
label: 'Find…',
@@ -2062,7 +2065,6 @@ async function appMain() {
},
]);
// Add Find submenu to Edit menu
editMenu.submenu.insert(
selectAllIndex + 1,
new MenuItem({
@@ -2082,7 +2084,7 @@ async function appMain() {
accelerator: 'CmdOrCtrl+T',
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) focusedWindow.webContents.send('set-view', '');
if (focusedWindow) focusedWindow.webContents.send('new-chat');
},
})
);
@@ -2098,7 +2100,6 @@ async function appMain() {
})
);
// Open goose to specific dir and set that as its working space
fileMenu.submenu.insert(
2,
new MenuItem({
@@ -2108,7 +2109,6 @@ async function appMain() {
})
);
// Add Recent Files submenu
const recentFilesSubmenu = buildRecentFilesMenu();
if (recentFilesSubmenu.length > 0) {
fileMenu.submenu.insert(
@@ -2122,9 +2122,6 @@ async function appMain() {
fileMenu.submenu.insert(4, new MenuItem({ type: 'separator' }));
// The Close Window item is here.
// Add menu item to tell the user about the keyboard shortcut
fileMenu.submenu.append(
new MenuItem({
label: 'Focus Goose Window',
@@ -2134,6 +2131,16 @@ async function appMain() {
},
})
);
fileMenu.submenu.append(
new MenuItem({
label: 'Quick Launcher',
accelerator: 'CmdOrCtrl+Alt+Shift+G',
click() {
createLauncher();
},
})
);
}
if (menu) {
@@ -2234,12 +2241,33 @@ async function appMain() {
ipcMain.on(
'create-chat-window',
(_, query, dir, version, resumeSessionId, viewType, recipeId) => {
(event, query, dir, version, resumeSessionId, viewType, recipeId) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
}
const isFromLauncher = query && !resumeSessionId && !viewType && !recipeId;
if (isFromLauncher) {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
const launcherWindowId = senderWindow?.id;
const allWindows = BrowserWindow.getAllWindows();
const existingWindows = allWindows.filter(
(win) => !win.isDestroyed() && win.id !== launcherWindowId
);
if (existingWindows.length > 0) {
const targetWindow = existingWindows[0];
targetWindow.show();
targetWindow.focus();
targetWindow.webContents.send('set-initial-message', query);
return;
}
}
// Otherwise, create a new window
createChat(
app,
query,
+31 -3
View File
@@ -6,8 +6,24 @@ import {
hasExtensionOverrides,
} from './store/extensionOverrides';
import type { FixedExtensionEntry } from './components/ConfigContext';
import { AppEvents } from './constants/events';
export function shouldShowNewChatTitle(session: Session): boolean {
return !session.user_set_name && session.message_count === 0;
}
export function resumeSession(session: Session, setView: setViewType) {
const eventDetail = {
sessionId: session.id,
initialMessage: undefined,
};
window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: eventDetail,
})
);
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
@@ -54,14 +70,13 @@ export async function createSession(
body,
throwOnError: true,
});
return newAgent.data;
}
export async function startNewSession(
workingDir: string,
initialText: string | undefined,
setView: setViewType,
workingDir: string,
options?: {
recipeId?: string;
recipeDeeplink?: string;
@@ -70,11 +85,24 @@ export async function startNewSession(
): Promise<Session> {
const session = await createSession(workingDir, options);
// Include session data so sidebar can add it immediately (before it has messages)
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED, { detail: { session } }));
const eventDetail = {
sessionId: session.id,
initialMessage: initialText,
};
window.dispatchEvent(
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
detail: eventDetail,
})
);
setView('pair', {
disableAnimation: true,
initialMessage: initialText,
resumeSessionId: session.id,
});
return session;
}
+43 -3
View File
@@ -124,9 +124,9 @@
--background-info: var(--color-blue-100);
--background-warning: var(--color-yellow-100);
--border-default: var(--color-neutral-900);
--border-input: var(--color-neutral-800);
--border-strong: var(--color-neutral-700);
--border-default: var(--color-neutral-800);
--border-input: var(--color-neutral-700);
--border-strong: var(--color-neutral-600);
--border-danger: var(--color-red-100);
--border-success: var(--color-green-100);
--border-warning: var(--color-yellow-100);
@@ -537,6 +537,46 @@ p > code.bg-inline-code {
padding-bottom: 24px;
}
/* Sidebar scrollbar styles */
.sidebar-scrollbar {
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.dark .sidebar-scrollbar {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.sidebar-scrollbar::-webkit-scrollbar {
width: 6px;
position: absolute;
right: 0;
}
.sidebar-scrollbar::-webkit-scrollbar-track {
background: transparent;
margin: 8px 0;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
border: none;
}
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
}
.sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
}
/* Virtualized list styles */
.virtualized-list {
scrollbar-width: thin;
+1 -1
View File
@@ -196,7 +196,7 @@ function ToastErrorContent({
</div>
<div className="flex-none flex items-center gap-2">
{showRecovery && (
<Button onClick={() => startNewSession(getInitialWorkingDir(), recoverHints, setView)}>
<Button onClick={() => startNewSession(recoverHints, setView, getInitialWorkingDir())}>
Ask goose
</Button>
)}
+12 -2
View File
@@ -41,9 +41,19 @@ export const createNavigationHandler = (navigate: NavigateFunction) => {
case 'chat':
navigate('/', { state: options });
break;
case 'pair':
navigate('/pair', { state: options });
case 'pair': {
// Put resumeSessionId in URL search params (not just state) so that:
// 1. The sidebar can read it to highlight the active session
// 2. Page refresh preserves which session is active
// 3. Browser back/forward navigation works correctly
const searchParams = new URLSearchParams();
if (options?.resumeSessionId) {
searchParams.set('resumeSessionId', options.resumeSessionId);
}
const url = searchParams.toString() ? `/pair?${searchParams.toString()}` : '/pair';
navigate(url, { state: options });
break;
}
case 'settings':
navigate('/settings', { state: options });
break;
+14 -1
View File
@@ -45,11 +45,24 @@ declare module '*.md?raw' {
export default value;
}
// Extend Window interface to include global recipe creation flag
declare global {
interface Window {
isCreatingRecipe?: boolean;
}
interface WindowEventMap {
'add-active-session': CustomEvent<{
sessionId: string;
initialMessage?: string;
}>;
'clear-initial-message': CustomEvent<{
sessionId: string;
}>;
responseStyleChanged: CustomEvent;
'session-created': CustomEvent<{ session?: import('./api').Session }>;
'session-deleted': CustomEvent<{ sessionId: string }>;
'session-renamed': CustomEvent<{ sessionId: string; newName: string }>;
}
}
export {};