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>; return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
}; };
export type QueuedDraft = {
content: string;
images: File[];
};
const getNotificationSessionSummary = ( const getNotificationSessionSummary = (
selectedSession: ProjectSession | null, selectedSession: ProjectSession | null,
fallbackInput: string, fallbackInput: string,
@@ -215,6 +220,18 @@ export function useChatComposerState({
>(null); >(null);
const inputValueRef = useRef(input); const inputValueRef = useRef(input);
const selectedProjectId = selectedProject?.projectId; 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( const handleBuiltInCommand = useCallback(
(result: CommandExecutionResult) => { (result: CommandExecutionResult) => {
@@ -555,7 +572,28 @@ export function useChatComposerState({
) => { ) => {
event.preventDefault(); event.preventDefault();
const currentInput = inputValueRef.current; 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; return;
} }
@@ -799,6 +837,40 @@ export function useChatComposerState({
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
}, [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 // 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 // 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. // inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
@@ -837,6 +909,28 @@ export function useChatComposerState({
} }
}, [input, selectedProjectId]); }, [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(() => { useEffect(() => {
if (!textareaRef.current) { if (!textareaRef.current) {
return; return;
@@ -1044,6 +1138,9 @@ export function useChatComposerState({
isDragActive, isDragActive,
openImagePicker: open, openImagePicker: open,
handleSubmit, handleSubmit,
queuedDraft,
editQueuedDraft,
deleteQueuedDraft,
handleVoiceTranscript, handleVoiceTranscript,
handleInputChange, handleInputChange,
handleKeyDown, handleKeyDown,

View File

@@ -11,7 +11,7 @@ export const safeLocalStorage = {
console.warn('localStorage quota exceeded, clearing old data'); console.warn('localStorage quota exceeded, clearing old data');
const keys = Object.keys(localStorage); 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) => { draftKeys.forEach((k) => {
localStorage.removeItem(k); localStorage.removeItem(k);
}); });

View File

@@ -174,6 +174,9 @@ function ChatInterface({
isDragActive, isDragActive,
openImagePicker, openImagePicker,
handleSubmit, handleSubmit,
queuedDraft,
editQueuedDraft,
deleteQueuedDraft,
handleVoiceTranscript, handleVoiceTranscript,
handleInputChange, handleInputChange,
handleKeyDown, handleKeyDown,
@@ -405,6 +408,9 @@ function ChatInterface({
onClearInput={handleClearInput} onClearInput={handleClearInput}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isDragActive={isDragActive} isDragActive={isDragActive}
queuedDraft={queuedDraft}
onEditQueuedDraft={editQueuedDraft}
onDeleteQueuedDraft={deleteQueuedDraft}
attachedImages={attachedImages} attachedImages={attachedImages}
onRemoveImage={(index) => onRemoveImage={(index) =>
setAttachedImages((previous) => setAttachedImages((previous) =>

View File

@@ -11,10 +11,11 @@ import type {
RefObject, RefObject,
TouchEvent, TouchEvent,
} from 'react'; } 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 { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { QueuedDraft } from '../../hooks/useChatComposerState';
import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app'; import type { ProviderModelOption } from '../../../../types/app';
@@ -35,6 +36,7 @@ import ImageAttachment from './ImageAttachment';
import VoiceInputButton from './VoiceInputButton'; import VoiceInputButton from './VoiceInputButton';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary'; import TokenUsageSummary from './TokenUsageSummary';
import QueuedMessageCard from './QueuedMessageCard';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
@@ -74,6 +76,9 @@ interface ChatComposerProps {
onClearInput: () => void; onClearInput: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean; isDragActive: boolean;
queuedDraft: QueuedDraft | null;
onEditQueuedDraft: () => void;
onDeleteQueuedDraft: () => void;
attachedImages: File[]; attachedImages: File[];
onRemoveImage: (index: number) => void; onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>; uploadingImages: Map<string, number>;
@@ -129,6 +134,9 @@ export default function ChatComposer({
onClearInput, onClearInput,
onSubmit, onSubmit,
isDragActive, isDragActive,
queuedDraft,
onEditQueuedDraft,
onDeleteQueuedDraft,
attachedImages, attachedImages,
onRemoveImage, onRemoveImage,
uploadingImages, uploadingImages,
@@ -267,6 +275,23 @@ export default function ChatComposer({
const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions); 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 ( 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"> <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 && ( {!hasPendingPermissions && (
@@ -285,6 +310,15 @@ export default function ChatComposer({
</div> </div>
)} )}
{queuedDraft && (
<QueuedMessageCard
content={queuedDraft.content}
imageCount={queuedDraft.images.length}
onEdit={onEditQueuedDraft}
onDelete={onDeleteQueuedDraft}
/>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]"> {!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
{showFileDropdown && filteredFiles.length > 0 && ( {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"> <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="flex items-center gap-2">
<div <div
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${ 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> </div>
<PromptInputSubmit <PromptInputSubmit
onClick={ onClick={
isLoading canQueueDraft
? onAbortSession ? (e: MouseEvent<HTMLButtonElement>) => {
: isRecording e.preventDefault();
? (e: MouseEvent<HTMLButtonElement>) => { onSubmit(e);
e.preventDefault(); }
voiceStop({ send: true }); : isLoading
} ? onAbortSession
: undefined : isRecording
? (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
voiceStop({ send: true });
}
: undefined
} }
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()} 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" 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> </PromptInputSubmit>
</div> </div>
</PromptInputFooter> </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; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
activeSessions: SessionActivityMap; activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
onProjectSelect: (project: Project) => void; onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void; onSessionSelect: (session: ProjectSession) => void;
onNewSession: (project: Project) => void; onNewSession: (project: Project) => void;

View File

@@ -26,6 +26,7 @@ function Sidebar({
selectedProject, selectedProject,
selectedSession, selectedSession,
activeSessions, activeSessions,
attentionSessionIds,
onProjectSelect, onProjectSelect,
onSessionSelect, onSessionSelect,
onNewSession, onNewSession,
@@ -163,6 +164,7 @@ function Sidebar({
getProjectSessions, getProjectSessions,
loadingMoreProjects, loadingMoreProjects,
activeSessions, activeSessions,
attentionSessionIds,
forceExpanded: searchMode === 'running', forceExpanded: searchMode === 'running',
isProjectStarred, isProjectStarred,
onEditingNameChange: setEditingName, onEditingNameChange: setEditingName,

View File

@@ -45,6 +45,7 @@ type SidebarProjectItemProps = {
) => void; ) => void;
onLoadMoreSessions: (projectId: string) => void; onLoadMoreSessions: (projectId: string) => void;
activeSessions: SessionActivityMap; activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
onNewSession: (project: Project) => void; onNewSession: (project: Project) => void;
onEditingSessionNameChange: (value: string) => void; onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void; onStartEditingSession: (sessionId: string, initialName: string) => void;
@@ -87,6 +88,7 @@ export default function SidebarProjectItem({
onDeleteSession, onDeleteSession,
onLoadMoreSessions, onLoadMoreSessions,
activeSessions, activeSessions,
attentionSessionIds,
onNewSession, onNewSession,
onEditingSessionNameChange, onEditingSessionNameChange,
onStartEditingSession, onStartEditingSession,
@@ -399,6 +401,7 @@ export default function SidebarProjectItem({
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)} hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
isLoadingMoreSessions={isLoadingMoreSessions} isLoadingMoreSessions={isLoadingMoreSessions}
activeSessions={activeSessions} activeSessions={activeSessions}
attentionSessionIds={attentionSessionIds}
currentTime={currentTime} currentTime={currentTime}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}

View File

@@ -29,6 +29,7 @@ export type SidebarProjectListProps = {
onLoadMoreSessions: (projectId: string) => void; onLoadMoreSessions: (projectId: string) => void;
loadingMoreProjects: Set<string>; loadingMoreProjects: Set<string>;
activeSessions: SessionActivityMap; activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
forceExpanded?: boolean; forceExpanded?: boolean;
isProjectStarred: (projectName: string) => boolean; isProjectStarred: (projectName: string) => boolean;
onEditingNameChange: (value: string) => void; onEditingNameChange: (value: string) => void;
@@ -75,6 +76,7 @@ export default function SidebarProjectList({
onLoadMoreSessions, onLoadMoreSessions,
loadingMoreProjects, loadingMoreProjects,
activeSessions, activeSessions,
attentionSessionIds,
forceExpanded = false, forceExpanded = false,
isProjectStarred, isProjectStarred,
onEditingNameChange, onEditingNameChange,
@@ -152,6 +154,7 @@ export default function SidebarProjectList({
onDeleteSession={onDeleteSession} onDeleteSession={onDeleteSession}
onLoadMoreSessions={onLoadMoreSessions} onLoadMoreSessions={onLoadMoreSessions}
activeSessions={activeSessions} activeSessions={activeSessions}
attentionSessionIds={attentionSessionIds}
onNewSession={onNewSession} onNewSession={onNewSession}
onEditingSessionNameChange={onEditingSessionNameChange} onEditingSessionNameChange={onEditingSessionNameChange}
onStartEditingSession={onStartEditingSession} onStartEditingSession={onStartEditingSession}

View File

@@ -17,6 +17,7 @@ type SidebarProjectSessionsProps = {
hasMoreSessions: boolean; hasMoreSessions: boolean;
isLoadingMoreSessions: boolean; isLoadingMoreSessions: boolean;
activeSessions: SessionActivityMap; activeSessions: SessionActivityMap;
attentionSessionIds: ReadonlySet<string>;
currentTime: Date; currentTime: Date;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
@@ -64,6 +65,7 @@ export default function SidebarProjectSessions({
hasMoreSessions, hasMoreSessions,
isLoadingMoreSessions, isLoadingMoreSessions,
activeSessions, activeSessions,
attentionSessionIds,
currentTime, currentTime,
editingSession, editingSession,
editingSessionName, editingSessionName,
@@ -124,6 +126,7 @@ export default function SidebarProjectSessions({
session={session} session={session}
selectedSession={selectedSession} selectedSession={selectedSession}
isProcessing={activeSessions.has(session.id)} isProcessing={activeSessions.has(session.id)}
needsAttention={attentionSessionIds.has(session.id)}
currentTime={currentTime} currentTime={currentTime}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}

View File

@@ -14,6 +14,7 @@ type SidebarSessionItemProps = {
session: SessionWithProvider; session: SessionWithProvider;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
isProcessing: boolean; isProcessing: boolean;
needsAttention: boolean;
currentTime: Date; currentTime: Date;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
@@ -65,6 +66,7 @@ export default function SidebarSessionItem({
session, session,
selectedSession, selectedSession,
isProcessing, isProcessing,
needsAttention,
currentTime, currentTime,
editingSession, editingSession,
editingSessionName, editingSessionName,
@@ -82,7 +84,8 @@ export default function SidebarSessionItem({
const isEditing = editingSession === session.id; const isEditing = editingSession === session.id;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
const editingContainerRef = useRef<HTMLDivElement>(null); 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 // 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 // would visually hide it. While editing, dismiss only when the user clicks outside
@@ -120,13 +123,23 @@ export default function SidebarSessionItem({
return ( return (
<div className="group relative"> <div className="group relative">
{showRecentIndicator && ( {(showAttentionIndicator || showRecentIndicator) && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"> <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 <div
role="status" role="status"
aria-label={t('tooltips.activeSessionIndicator')} aria-label={showAttentionIndicator
className="h-2 w-2 animate-pulse rounded-full bg-green-500" ? 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> </Tooltip>
</div> </div>

View File

@@ -352,6 +352,7 @@ export function useProjectsState({
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null); const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);
const [attentionSessionIds, setAttentionSessionIds] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<AppTab>(readPersistedTab); const [activeTab, setActiveTab] = useState<AppTab>(readPersistedTab);
useEffect(() => { useEffect(() => {
@@ -405,6 +406,43 @@ export function useProjectsState({
const activeSessionsRef = useRef(activeSessions); const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = 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 = {}) => { const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try { try {
if (showLoadingState) { if (showLoadingState) {
@@ -598,6 +636,25 @@ export function useProjectsState({
return; 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') { if (event.kind !== 'session_upserted') {
return; return;
} }
@@ -617,6 +674,8 @@ export function useProjectsState({
&& !activeSessionsRef.current.has(upsert.sessionId) && !activeSessionsRef.current.has(upsert.sessionId)
) { ) {
setExternalMessageUpdate((prev) => prev + 1); setExternalMessageUpdate((prev) => prev + 1);
} else {
markSessionAttention(upsert.sessionId);
} }
setProjects((previousProjects) => { setProjects((previousProjects) => {
@@ -702,7 +761,7 @@ export function useProjectsState({
}; };
return subscribe(handleEvent); return subscribe(handleEvent);
}, [navigate, sessionId, subscribe]); }, [markSessionAttention, navigate, sessionId, subscribe]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -713,6 +772,10 @@ export function useProjectsState({
}; };
}, []); }, []);
useEffect(() => {
clearSessionAttention(selectedSession?.id ?? sessionId ?? null);
}, [clearSessionAttention, selectedSession?.id, sessionId]);
useEffect(() => { useEffect(() => {
if (!sessionId || projects.length === 0) { if (!sessionId || projects.length === 0) {
return; return;
@@ -774,6 +837,7 @@ export function useProjectsState({
const handleSessionSelect = useCallback( const handleSessionSelect = useCallback(
(session: ProjectSession) => { (session: ProjectSession) => {
clearSessionAttention(session.id);
setSelectedSession(session); setSelectedSession(session);
if (activeTab === 'tasks' || activeTab === 'browser') { if (activeTab === 'tasks' || activeTab === 'browser') {
@@ -795,7 +859,7 @@ export function useProjectsState({
navigate(`/session/${session.id}`); navigate(`/session/${session.id}`);
}, },
[activeTab, isMobile, navigate, selectedProject?.projectId], [activeTab, clearSessionAttention, isMobile, navigate, selectedProject?.projectId],
); );
const handleNewSession = useCallback( const handleNewSession = useCallback(
@@ -815,6 +879,8 @@ export function useProjectsState({
const handleSessionDelete = useCallback( const handleSessionDelete = useCallback(
(sessionIdToDelete: string) => { (sessionIdToDelete: string) => {
clearSessionAttention(sessionIdToDelete);
if (selectedSession?.id === sessionIdToDelete) { if (selectedSession?.id === sessionIdToDelete) {
setSelectedSession(null); setSelectedSession(null);
navigate('/'); navigate('/');
@@ -824,7 +890,7 @@ export function useProjectsState({
prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)), prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
); );
}, },
[navigate, selectedSession?.id], [clearSessionAttention, navigate, selectedSession?.id],
); );
const handleSidebarRefresh = useCallback(async () => { const handleSidebarRefresh = useCallback(async () => {
@@ -945,6 +1011,7 @@ export function useProjectsState({
selectedProject, selectedProject,
selectedSession, selectedSession,
activeSessions, activeSessions,
attentionSessionIds,
onProjectSelect: handleProjectSelect, onProjectSelect: handleProjectSelect,
onSessionSelect: handleSessionSelect, onSessionSelect: handleSessionSelect,
onNewSession: handleNewSession, onNewSession: handleNewSession,
@@ -961,6 +1028,7 @@ export function useProjectsState({
isMobile, isMobile,
}), }),
[ [
attentionSessionIds,
handleNewSession, handleNewSession,
handleProjectDelete, handleProjectDelete,
handleProjectSelect, handleProjectSelect,

View File

@@ -140,12 +140,22 @@
"stop": "Stop", "stop": "Stop",
"hintText": { "hintText": {
"ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands", "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)", "clickToChangeMode": "Click to change permission mode (or press Tab in input)",
"showAllCommands": "Show all commands", "showAllCommands": "Show all commands",
"clearInput": "Clear input", "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": { "providerSelection": {
"title": "Choose Your AI Assistant", "title": "Choose Your AI Assistant",