diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9dd1bbc0..8f4e069d 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -145,6 +145,11 @@ const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; +export type QueuedDraft = { + content: string; + images: File[]; +}; + const getNotificationSessionSummary = ( selectedSession: ProjectSession | null, fallbackInput: string, @@ -215,6 +220,18 @@ export function useChatComposerState({ >(null); const inputValueRef = useRef(input); const selectedProjectId = selectedProject?.projectId; + // Prefer the stable backend-allocated id (selectedSession.id) but fall back + // to currentSessionId for a just-established session that hasn't been + // handed back to the parent's `selectedSession` prop yet. + const sessionKey = selectedSession?.id || currentSessionId || null; + + const [queuedDraft, setQueuedDraft] = useState(() => { + if (typeof window === 'undefined' || !sessionKey) { + return null; + } + const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`); + return saved ? { content: saved, images: [] } : null; + }); const handleBuiltInCommand = useCallback( (result: CommandExecutionResult) => { @@ -555,7 +572,28 @@ export function useChatComposerState({ ) => { event.preventDefault(); const currentInput = inputValueRef.current; - if (!currentInput.trim() || isLoading || !selectedProject) { + if (!currentInput.trim() || !selectedProject) { + return; + } + + // A turn is already in flight: stash this message instead of sending it. + // It's auto-flushed (re-running this same function) once the turn ends, + // so it still goes through slash-command interception, image upload, etc. + if (isLoading) { + setQueuedDraft({ content: currentInput, images: attachedImages }); + setInput(''); + inputValueRef.current = ''; + setAttachedImages([]); + setUploadingImages(new Map()); + setImageErrors(new Map()); + resetCommandMenuState(); + setIsTextareaExpanded(false); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + if (selectedProject) { + safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); + } return; } @@ -799,6 +837,40 @@ export function useChatComposerState({ handleSubmitRef.current = handleSubmit; }, [handleSubmit]); + // Once the in-flight turn ends, replay the queued draft through the normal + // submit path (slash commands, image upload, etc. all still apply). + const wasLoadingRef = useRef(isLoading); + useEffect(() => { + const wasLoading = wasLoadingRef.current; + wasLoadingRef.current = isLoading; + if (!wasLoading || isLoading || !queuedDraft) { + return; + } + + setQueuedDraft(null); + setInput(queuedDraft.content); + inputValueRef.current = queuedDraft.content; + setAttachedImages(queuedDraft.images); + setTimeout(() => { + handleSubmitRef.current?.(createFakeSubmitEvent()); + }, 0); + }, [isLoading, queuedDraft]); + + const editQueuedDraft = useCallback(() => { + if (!queuedDraft) { + return; + } + setQueuedDraft(null); + setInput(queuedDraft.content); + inputValueRef.current = queuedDraft.content; + setAttachedImages(queuedDraft.images); + textareaRef.current?.focus(); + }, [queuedDraft]); + + const deleteQueuedDraft = useCallback(() => { + setQueuedDraft(null); + }, []); + // A voice transcript either fills the input (to edit before sending) or, when the // user tapped "stop and send", is submitted straight away. Mirror the value into // inputValueRef synchronously so handleSubmit reads the new text, not the stale state. @@ -837,6 +909,28 @@ export function useChatComposerState({ } }, [input, selectedProjectId]); + // Switching sessions swaps in that session's queued draft (image + // attachments can't survive a reload, so only the text is restored). + useEffect(() => { + if (!sessionKey) { + setQueuedDraft(null); + return; + } + const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`); + setQueuedDraft(saved ? { content: saved, images: [] } : null); + }, [sessionKey]); + + useEffect(() => { + if (!sessionKey) { + return; + } + if (queuedDraft?.content) { + safeLocalStorage.setItem(`queued_message_${sessionKey}`, queuedDraft.content); + } else { + safeLocalStorage.removeItem(`queued_message_${sessionKey}`); + } + }, [queuedDraft, sessionKey]); + useEffect(() => { if (!textareaRef.current) { return; @@ -1044,6 +1138,9 @@ export function useChatComposerState({ isDragActive, openImagePicker: open, handleSubmit, + queuedDraft, + editQueuedDraft, + deleteQueuedDraft, handleVoiceTranscript, handleInputChange, handleKeyDown, diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index 367a305e..0365679b 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -11,7 +11,7 @@ export const safeLocalStorage = { console.warn('localStorage quota exceeded, clearing old data'); const keys = Object.keys(localStorage); - const draftKeys = keys.filter((k) => k.startsWith('draft_input_')); + const draftKeys = keys.filter((k) => k.startsWith('draft_input_') || k.startsWith('queued_message_')); draftKeys.forEach((k) => { localStorage.removeItem(k); }); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 76afbe4d..f46d3f1a 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -174,6 +174,9 @@ function ChatInterface({ isDragActive, openImagePicker, handleSubmit, + queuedDraft, + editQueuedDraft, + deleteQueuedDraft, handleVoiceTranscript, handleInputChange, handleKeyDown, @@ -405,6 +408,9 @@ function ChatInterface({ onClearInput={handleClearInput} onSubmit={handleSubmit} isDragActive={isDragActive} + queuedDraft={queuedDraft} + onEditQueuedDraft={editQueuedDraft} + onDeleteQueuedDraft={deleteQueuedDraft} attachedImages={attachedImages} onRemoveImage={(index) => setAttachedImages((previous) => diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index ba35dfd8..0abd2463 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,10 +11,11 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check, ArrowUpIcon } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; +import type { QueuedDraft } from '../../hooks/useChatComposerState'; import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { ProviderModelOption } from '../../../../types/app'; @@ -35,6 +36,7 @@ import ImageAttachment from './ImageAttachment'; import VoiceInputButton from './VoiceInputButton'; import PermissionRequestsBanner from './PermissionRequestsBanner'; import TokenUsageSummary from './TokenUsageSummary'; +import QueuedMessageCard from './QueuedMessageCard'; interface MentionableFile { name: string; @@ -74,6 +76,9 @@ interface ChatComposerProps { onClearInput: () => void; onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void; isDragActive: boolean; + queuedDraft: QueuedDraft | null; + onEditQueuedDraft: () => void; + onDeleteQueuedDraft: () => void; attachedImages: File[]; onRemoveImage: (index: number) => void; uploadingImages: Map; @@ -129,6 +134,9 @@ export default function ChatComposer({ onClearInput, onSubmit, isDragActive, + queuedDraft, + onEditQueuedDraft, + onDeleteQueuedDraft, attachedImages, onRemoveImage, uploadingImages, @@ -267,6 +275,23 @@ export default function ChatComposer({ const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasActivityIndicator = Boolean(activity && !hasPendingPermissions); + const hasQueuedDraft = Boolean(queuedDraft); + const canQueueDraft = isLoading && Boolean(input.trim()); + const submitHint = canQueueDraft + ? hasQueuedDraft + ? t('input.hintText.updateQueued', { defaultValue: 'Enter to update queued message' }) + : t('input.hintText.queue', { defaultValue: 'Enter to queue your next message' }) + : sendByCtrlEnter + ? t('input.hintText.ctrlEnter') + : t('input.hintText.enter'); + const submitAriaLabel = canQueueDraft + ? hasQueuedDraft + ? t('input.queue.update', { defaultValue: 'Update queued message' }) + : t('input.queue.sendNext', { defaultValue: 'Queue next message' }) + : isLoading + ? t('input.stop') + : t('input.send'); + return (
{!hasPendingPermissions && ( @@ -285,6 +310,15 @@ export default function ChatComposer({
)} + {queuedDraft && ( + + )} + {!hasQuestionPanel &&
{showFileDropdown && filteredFiles.length > 0 && (
@@ -540,26 +574,37 @@ export default function ChatComposer({
- {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')} + {submitHint}
) => { - e.preventDefault(); - voiceStop({ send: true }); - } - : undefined + canQueueDraft + ? (e: MouseEvent) => { + e.preventDefault(); + onSubmit(e); + } + : isLoading + ? onAbortSession + : isRecording + ? (e: MouseEvent) => { + e.preventDefault(); + voiceStop({ send: true }); + } + : undefined } disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()} + aria-label={submitAriaLabel} + title={submitAriaLabel} className="h-10 w-10 sm:h-10 sm:w-10" > - {isTranscribing ? : undefined} + {isTranscribing ? ( + + ) : canQueueDraft ? ( + + ) : undefined}
diff --git a/src/components/chat/view/subcomponents/QueuedMessageCard.tsx b/src/components/chat/view/subcomponents/QueuedMessageCard.tsx new file mode 100644 index 00000000..b3f12bf0 --- /dev/null +++ b/src/components/chat/view/subcomponents/QueuedMessageCard.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next'; +import { PencilIcon, XIcon } from 'lucide-react'; + +interface QueuedMessageCardProps { + content: string; + imageCount?: number; + onEdit: () => void; + onDelete: () => void; +} + +export default function QueuedMessageCard({ content, imageCount = 0, onEdit, onDelete }: QueuedMessageCardProps) { + const { t } = useTranslation('chat'); + + return ( +
+
+ + +
+
+ {t('input.queue.label', { defaultValue: 'Queued' })} + + · {t('input.queue.willSend', { defaultValue: 'Will send when this finishes' })} + +
+

{content}

+ {imageCount > 0 && ( +

+ {imageCount} {imageCount === 1 ? 'image' : 'images'} attached +

+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 672fdd34..f5393d16 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -42,6 +42,7 @@ export type SidebarProps = { selectedProject: Project | null; selectedSession: ProjectSession | null; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onNewSession: (project: Project) => void; diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 5e544b08..3189b699 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -26,6 +26,7 @@ function Sidebar({ selectedProject, selectedSession, activeSessions, + attentionSessionIds, onProjectSelect, onSessionSelect, onNewSession, @@ -163,6 +164,7 @@ function Sidebar({ getProjectSessions, loadingMoreProjects, activeSessions, + attentionSessionIds, forceExpanded: searchMode === 'running', isProjectStarred, onEditingNameChange: setEditingName, diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx index 618e0326..4382240c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx @@ -45,6 +45,7 @@ type SidebarProjectItemProps = { ) => void; onLoadMoreSessions: (projectId: string) => void; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; onNewSession: (project: Project) => void; onEditingSessionNameChange: (value: string) => void; onStartEditingSession: (sessionId: string, initialName: string) => void; @@ -87,6 +88,7 @@ export default function SidebarProjectItem({ onDeleteSession, onLoadMoreSessions, activeSessions, + attentionSessionIds, onNewSession, onEditingSessionNameChange, onStartEditingSession, @@ -399,6 +401,7 @@ export default function SidebarProjectItem({ hasMoreSessions={Boolean(project.sessionMeta?.hasMore)} isLoadingMoreSessions={isLoadingMoreSessions} activeSessions={activeSessions} + attentionSessionIds={attentionSessionIds} currentTime={currentTime} editingSession={editingSession} editingSessionName={editingSessionName} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx index 90e6ec7c..84b9302f 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx @@ -29,6 +29,7 @@ export type SidebarProjectListProps = { onLoadMoreSessions: (projectId: string) => void; loadingMoreProjects: Set; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; forceExpanded?: boolean; isProjectStarred: (projectName: string) => boolean; onEditingNameChange: (value: string) => void; @@ -75,6 +76,7 @@ export default function SidebarProjectList({ onLoadMoreSessions, loadingMoreProjects, activeSessions, + attentionSessionIds, forceExpanded = false, isProjectStarred, onEditingNameChange, @@ -152,6 +154,7 @@ export default function SidebarProjectList({ onDeleteSession={onDeleteSession} onLoadMoreSessions={onLoadMoreSessions} activeSessions={activeSessions} + attentionSessionIds={attentionSessionIds} onNewSession={onNewSession} onEditingSessionNameChange={onEditingSessionNameChange} onStartEditingSession={onStartEditingSession} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx index 1c8763bb..df08e4f3 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx @@ -17,6 +17,7 @@ type SidebarProjectSessionsProps = { hasMoreSessions: boolean; isLoadingMoreSessions: boolean; activeSessions: SessionActivityMap; + attentionSessionIds: ReadonlySet; currentTime: Date; editingSession: string | null; editingSessionName: string; @@ -64,6 +65,7 @@ export default function SidebarProjectSessions({ hasMoreSessions, isLoadingMoreSessions, activeSessions, + attentionSessionIds, currentTime, editingSession, editingSessionName, @@ -124,6 +126,7 @@ export default function SidebarProjectSessions({ session={session} selectedSession={selectedSession} isProcessing={activeSessions.has(session.id)} + needsAttention={attentionSessionIds.has(session.id)} currentTime={currentTime} editingSession={editingSession} editingSessionName={editingSessionName} diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 85a25250..91ea437c 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -14,6 +14,7 @@ type SidebarSessionItemProps = { session: SessionWithProvider; selectedSession: ProjectSession | null; isProcessing: boolean; + needsAttention: boolean; currentTime: Date; editingSession: string | null; editingSessionName: string; @@ -65,6 +66,7 @@ export default function SidebarSessionItem({ session, selectedSession, isProcessing, + needsAttention, currentTime, editingSession, editingSessionName, @@ -82,7 +84,8 @@ export default function SidebarSessionItem({ const isEditing = editingSession === session.id; const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const editingContainerRef = useRef(null); - const showRecentIndicator = !isProcessing && sessionView.isActive; + const showAttentionIndicator = needsAttention && !isSelected; + const showRecentIndicator = !showAttentionIndicator && !isProcessing && sessionView.isActive; // The rename panel sits inside a group-hover opacity wrapper, so leaving the row // would visually hide it. While editing, dismiss only when the user clicks outside @@ -120,13 +123,23 @@ export default function SidebarSessionItem({ return (
- {showRecentIndicator && ( + {(showAttentionIndicator || showRecentIndicator) && (
- +
diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index a2cdbd06..7ca7c8c4 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -352,6 +352,7 @@ export function useProjectsState({ const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); + const [attentionSessionIds, setAttentionSessionIds] = useState>(new Set()); const [activeTab, setActiveTab] = useState(readPersistedTab); useEffect(() => { @@ -405,6 +406,43 @@ export function useProjectsState({ const activeSessionsRef = useRef(activeSessions); activeSessionsRef.current = activeSessions; + const markSessionAttention = useCallback((targetSessionId?: string | null) => { + if (!targetSessionId) { + return; + } + + const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null; + if (targetSessionId === viewedSessionId) { + return; + } + + setAttentionSessionIds((previous) => { + if (previous.has(targetSessionId)) { + return previous; + } + + const next = new Set(previous); + next.add(targetSessionId); + return next; + }); + }, [sessionId]); + + const clearSessionAttention = useCallback((targetSessionId?: string | null) => { + if (!targetSessionId) { + return; + } + + setAttentionSessionIds((previous) => { + if (!previous.has(targetSessionId)) { + return previous; + } + + const next = new Set(previous); + next.delete(targetSessionId); + return next; + }); + }, []); + const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => { try { if (showLoadingState) { @@ -598,6 +636,25 @@ export function useProjectsState({ return; } + const eventSessionId = typeof event.sessionId === 'string' && event.sessionId + ? event.sessionId + : null; + const viewedSessionId = selectedSessionRef.current?.id ?? sessionId ?? null; + + if ( + eventSessionId + && eventSessionId !== viewedSessionId + && event.kind !== 'chat_subscribed' + && event.kind !== 'loading_progress' + && event.kind !== 'session_upserted' + && event.kind !== 'status' + && event.kind !== 'stream_end' + && event.kind !== 'permission_cancelled' + && event.kind !== 'websocket_reconnected' + ) { + markSessionAttention(eventSessionId); + } + if (event.kind !== 'session_upserted') { return; } @@ -617,6 +674,8 @@ export function useProjectsState({ && !activeSessionsRef.current.has(upsert.sessionId) ) { setExternalMessageUpdate((prev) => prev + 1); + } else { + markSessionAttention(upsert.sessionId); } setProjects((previousProjects) => { @@ -702,7 +761,7 @@ export function useProjectsState({ }; return subscribe(handleEvent); - }, [navigate, sessionId, subscribe]); + }, [markSessionAttention, navigate, sessionId, subscribe]); useEffect(() => { return () => { @@ -713,6 +772,10 @@ export function useProjectsState({ }; }, []); + useEffect(() => { + clearSessionAttention(selectedSession?.id ?? sessionId ?? null); + }, [clearSessionAttention, selectedSession?.id, sessionId]); + useEffect(() => { if (!sessionId || projects.length === 0) { return; @@ -774,6 +837,7 @@ export function useProjectsState({ const handleSessionSelect = useCallback( (session: ProjectSession) => { + clearSessionAttention(session.id); setSelectedSession(session); if (activeTab === 'tasks' || activeTab === 'browser') { @@ -795,7 +859,7 @@ export function useProjectsState({ navigate(`/session/${session.id}`); }, - [activeTab, isMobile, navigate, selectedProject?.projectId], + [activeTab, clearSessionAttention, isMobile, navigate, selectedProject?.projectId], ); const handleNewSession = useCallback( @@ -815,6 +879,8 @@ export function useProjectsState({ const handleSessionDelete = useCallback( (sessionIdToDelete: string) => { + clearSessionAttention(sessionIdToDelete); + if (selectedSession?.id === sessionIdToDelete) { setSelectedSession(null); navigate('/'); @@ -824,7 +890,7 @@ export function useProjectsState({ prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)), ); }, - [navigate, selectedSession?.id], + [clearSessionAttention, navigate, selectedSession?.id], ); const handleSidebarRefresh = useCallback(async () => { @@ -945,6 +1011,7 @@ export function useProjectsState({ selectedProject, selectedSession, activeSessions, + attentionSessionIds, onProjectSelect: handleProjectSelect, onSessionSelect: handleSessionSelect, onNewSession: handleNewSession, @@ -961,6 +1028,7 @@ export function useProjectsState({ isMobile, }), [ + attentionSessionIds, handleNewSession, handleProjectDelete, handleProjectSelect, diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 656fa328..d6f21c25 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -140,12 +140,22 @@ "stop": "Stop", "hintText": { "ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands", - "enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands" + "enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands", + "queue": "Enter to queue your next message", + "updateQueued": "Enter to update queued message" }, "clickToChangeMode": "Click to change permission mode (or press Tab in input)", "showAllCommands": "Show all commands", "clearInput": "Clear input", - "scrollToBottom": "Scroll to bottom" + "scrollToBottom": "Scroll to bottom", + "queue": { + "sendNext": "Queue next message", + "update": "Update queued message", + "label": "Queued", + "willSend": "Will send when this finishes", + "edit": "Edit queued message", + "delete": "Delete queued message" + } }, "providerSelection": { "title": "Choose Your AI Assistant",