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>

View File

@@ -352,6 +352,7 @@ export function useProjectsState({
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);
const [attentionSessionIds, setAttentionSessionIds] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<AppTab>(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,

View File

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