168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
/**
|
|
* Hub Component
|
|
*
|
|
* The empty-chat landing screen. Visually it's "Pair with no messages yet" —
|
|
* a large time + greeting above a centered, narrower ChatInput. Submitting
|
|
* creates a session and navigates to /pair so the rest of the chat lifecycle
|
|
* lives there.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { defineMessages, useIntl } from '../i18n';
|
|
import { AppEvents } from '../constants/events';
|
|
import ChatInput from './ChatInput';
|
|
import { ChatInputCard } from './ChatInputCard';
|
|
import { ChatState } from '../types/chatState';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import { View, ViewOptions } from '../utils/navigationUtils';
|
|
import { useConfig } from './ConfigContext';
|
|
import { getInitialWorkingDir } from '../utils/workingDir';
|
|
import { createSession } from '../sessions';
|
|
import LoadingGoose from './LoadingGoose';
|
|
import { UserInput } from '../types/message';
|
|
import {
|
|
createNextChatExtensionDraft,
|
|
selectNextChatExtensions,
|
|
type NextChatExtensionDraft,
|
|
} from '../utils/nextChatExtensions';
|
|
import { formatAcpError } from '../acp/errors';
|
|
import { toastError } from '../toasts';
|
|
|
|
const i18n = defineMessages({
|
|
goodMorning: { id: 'hub.goodMorning', defaultMessage: 'Good morning' },
|
|
goodAfternoon: { id: 'hub.goodAfternoon', defaultMessage: 'Good afternoon' },
|
|
goodEvening: { id: 'hub.goodEvening', defaultMessage: 'Good evening' },
|
|
});
|
|
|
|
function useClock(): { time: string; meridiem: string; hour: number } {
|
|
const [now, setNow] = useState(() => new Date());
|
|
useEffect(() => {
|
|
const interval = setInterval(() => setNow(new Date()), 30_000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const hour = now.getHours();
|
|
const minutes = now.getMinutes();
|
|
const meridiem = hour >= 12 ? 'PM' : 'AM';
|
|
const displayHour = ((hour + 11) % 12) + 1;
|
|
const time = `${displayHour}:${String(minutes).padStart(2, '0')}`;
|
|
return { time, meridiem, hour };
|
|
}
|
|
|
|
export default function Hub({
|
|
setView,
|
|
}: {
|
|
setView: (view: View, viewOptions?: ViewOptions) => void;
|
|
}) {
|
|
const intl = useIntl();
|
|
const { extensionsList } = useConfig();
|
|
const [workingDir, setWorkingDir] = useState(getInitialWorkingDir());
|
|
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
|
const [nextChatExtensionDraft, setNextChatExtensionDraft] =
|
|
useState<NextChatExtensionDraft | null>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const { time, meridiem, hour } = useClock();
|
|
|
|
const greeting = useMemo(() => {
|
|
if (hour < 12) return intl.formatMessage(i18n.goodMorning);
|
|
if (hour < 18) return intl.formatMessage(i18n.goodAfternoon);
|
|
return intl.formatMessage(i18n.goodEvening);
|
|
}, [intl, hour]);
|
|
|
|
const draftForMenu = useMemo(
|
|
() => nextChatExtensionDraft ?? createNextChatExtensionDraft(extensionsList),
|
|
[extensionsList, nextChatExtensionDraft]
|
|
);
|
|
|
|
// rAF is more reliable than autoFocus across async render boundaries.
|
|
useEffect(() => {
|
|
const frameId = requestAnimationFrame(() => {
|
|
inputRef.current?.focus();
|
|
});
|
|
return () => cancelAnimationFrame(frameId);
|
|
}, []);
|
|
|
|
const handleNextChatExtensionDraftChange = useCallback((draft: NextChatExtensionDraft) => {
|
|
setNextChatExtensionDraft(draft);
|
|
}, []);
|
|
|
|
const handleSubmit = async (input: UserInput) => {
|
|
const { msg: userMessage, images } = input;
|
|
if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return;
|
|
|
|
setIsCreatingSession(true);
|
|
|
|
try {
|
|
const selectedExtensions = nextChatExtensionDraft
|
|
? selectNextChatExtensions(extensionsList, nextChatExtensionDraft)
|
|
: [];
|
|
const sessionOptions =
|
|
selectedExtensions.length > 0
|
|
? { extensionConfigs: selectedExtensions }
|
|
: { allExtensions: extensionsList };
|
|
|
|
const session = await createSession(workingDir, sessionOptions);
|
|
setNextChatExtensionDraft(null);
|
|
|
|
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
|
window.dispatchEvent(
|
|
new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, {
|
|
detail: { sessionId: session.id, initialMessage: { msg: userMessage, images } },
|
|
})
|
|
);
|
|
|
|
setView('pair', {
|
|
disableAnimation: true,
|
|
resumeSessionId: session.id,
|
|
initialMessage: { msg: userMessage, images },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create session:', error);
|
|
toastError({ title: "Couldn't start chat", msg: formatAcpError(error) });
|
|
setIsCreatingSession(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0 items-center justify-center px-6 relative">
|
|
<div className="w-full max-w-2xl">
|
|
<div className="flex items-baseline gap-2 mb-1">
|
|
<span className="text-6xl font-light text-text-primary tracking-tight tabular-nums">
|
|
{time}
|
|
</span>
|
|
<span className="text-2xl font-light text-text-secondary">{meridiem}</span>
|
|
</div>
|
|
<p className="text-xl text-text-secondary mb-6">{greeting}</p>
|
|
|
|
<ChatInputCard>
|
|
<ChatInput
|
|
sessionId={null}
|
|
handleSubmit={handleSubmit}
|
|
chatState={isCreatingSession ? ChatState.LoadingConversation : ChatState.Idle}
|
|
onStop={() => {}}
|
|
initialValue=""
|
|
setView={setView}
|
|
totalTokens={0}
|
|
accumulatedInputTokens={0}
|
|
accumulatedOutputTokens={0}
|
|
droppedFiles={[]}
|
|
onFilesProcessed={() => {}}
|
|
messages={[]}
|
|
disableAnimation={false}
|
|
onWorkingDirChange={setWorkingDir}
|
|
inputRef={inputRef}
|
|
nextChatExtensionDraft={draftForMenu}
|
|
onNextChatExtensionDraftChange={handleNextChatExtensionDraftChange}
|
|
/>
|
|
</ChatInputCard>
|
|
</div>
|
|
|
|
{isCreatingSession && (
|
|
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
|
|
<LoadingGoose chatState={ChatState.LoadingConversation} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|