feat: support message queue and add attention indicator

This commit is contained in:
Haileyesus
2026-07-02 21:36:59 +03:00
parent 348427ce95
commit cc2811126b
13 changed files with 332 additions and 24 deletions

View File

@@ -145,6 +145,11 @@ const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
};
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<QueuedDraft | null>(() => {
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,

View File

@@ -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);
});

View File

@@ -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) =>

View File

@@ -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<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
queuedDraft: QueuedDraft | null;
onEditQueuedDraft: () => void;
onDeleteQueuedDraft: () => void;
attachedImages: File[];
onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>;
@@ -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 (
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && (
@@ -285,6 +310,15 @@ export default function ChatComposer({
</div>
)}
{queuedDraft && (
<QueuedMessageCard
content={queuedDraft.content}
imageCount={queuedDraft.images.length}
onEdit={onEditQueuedDraft}
onDelete={onDeleteQueuedDraft}
/>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
@@ -540,26 +574,37 @@ export default function ChatComposer({
<div className="flex items-center gap-2">
<div
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100'
input.trim() && !canQueueDraft ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
{submitHint}
</div>
<PromptInputSubmit
onClick={
isLoading
? onAbortSession
: isRecording
? (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
voiceStop({ send: true });
}
: undefined
canQueueDraft
? (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onSubmit(e);
}
: isLoading
? onAbortSession
: isRecording
? (e: MouseEvent<HTMLButtonElement>) => {
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined}
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : canQueueDraft ? (
<ArrowUpIcon className="h-4 w-4" />
) : undefined}
</PromptInputSubmit>
</div>
</PromptInputFooter>

View File

@@ -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 (
<div className="settings-content-enter mx-auto mb-2 max-w-[54.25rem] rounded-xl border border-dashed border-primary/25 bg-primary/[0.04] px-3 py-2">
<div className="flex items-start gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-primary/70">
<span>{t('input.queue.label', { defaultValue: 'Queued' })}</span>
<span className="normal-case text-muted-foreground/60">
· {t('input.queue.willSend', { defaultValue: 'Will send when this finishes' })}
</span>
</div>
<p className="mt-0.5 line-clamp-2 break-words text-sm text-foreground/90">{content}</p>
{imageCount > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">
{imageCount} {imageCount === 1 ? 'image' : 'images'} attached
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={onEdit}
aria-label={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
title={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<PencilIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onDelete}
aria-label={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
title={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -42,6 +42,7 @@ export type SidebarProps = {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void;
onNewSession: (project: Project) => void;

View File

@@ -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,

View File

@@ -45,6 +45,7 @@ type SidebarProjectItemProps = {
) => void;
onLoadMoreSessions: (projectId: string) => void;
activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
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}

View File

@@ -29,6 +29,7 @@ export type SidebarProjectListProps = {
onLoadMoreSessions: (projectId: string) => void;
loadingMoreProjects: Set<string>;
activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
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}

View File

@@ -17,6 +17,7 @@ type SidebarProjectSessionsProps = {
hasMoreSessions: boolean;
isLoadingMoreSessions: boolean;
activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
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}

View File

@@ -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<HTMLDivElement>(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 (
<div className="group relative">
{showRecentIndicator && (
{(showAttentionIndicator || showRecentIndicator) && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
<Tooltip
content={showAttentionIndicator
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
: t('tooltips.activeSessionIndicator')}
position="right"
>
<div
role="status"
aria-label={t('tooltips.activeSessionIndicator')}
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
aria-label={showAttentionIndicator
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
: t('tooltips.activeSessionIndicator')}
className={cn(
'h-2 w-2 animate-pulse rounded-full',
showAttentionIndicator ? 'bg-amber-500' : 'bg-green-500',
)}
/>
</Tooltip>
</div>