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