mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 14:12:40 +08:00
feat: support message queue and add attention indicator
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
57
src/components/chat/view/subcomponents/QueuedMessageCard.tsx
Normal file
57
src/components/chat/view/subcomponents/QueuedMessageCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user