diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cfca554b..257b5a87 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1875,6 +1875,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo isWaitingForPermission={working.isWaitingForPermission} wasAborted={working.wasAborted} abortActive={working.abortActive} + retryInfo={working.retryInfo} showAbortStatus={showAbortStatus} /> diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 3012ea75..a4571492 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -53,6 +53,7 @@ interface StatusRowProps { isWaitingForPermission?: boolean; wasAborted?: boolean; abortActive?: boolean; + retryInfo?: { attempt?: number; next?: number } | null; // Abort state (for mobile/vscode) showAbort?: boolean; onAbort?: () => void; @@ -67,6 +68,7 @@ export const StatusRow: React.FC = ({ isWaitingForPermission, wasAborted, abortActive, + retryInfo, showAbort, onAbort, showAbortStatus, @@ -211,6 +213,7 @@ export const StatusRow: React.FC = ({ statusText={statusText} isGenericStatus={isGenericStatus} isWaitingForPermission={isWaitingForPermission} + retryInfo={retryInfo} /> ) : null} diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 90cc0a92..2775140c 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -6,6 +6,7 @@ interface WorkingPlaceholderProps { statusText: string | null; isGenericStatus?: boolean; isWaitingForPermission?: boolean; + retryInfo?: { attempt?: number; next?: number } | null; } const STATUS_DISPLAY_TIME_MS = 1200; @@ -15,6 +16,7 @@ export function WorkingPlaceholder({ statusText, isGenericStatus, isWaitingForPermission, + retryInfo, }: WorkingPlaceholderProps) { const [displayedText, setDisplayedText] = React.useState(null); const [displayedPermission, setDisplayedPermission] = React.useState(false); @@ -23,6 +25,35 @@ export function WorkingPlaceholder({ const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null); const processQueueTimerRef = React.useRef | null>(null); + // Countdown state for retry mode + const retryNextRef = React.useRef(null); + const retryStartRef = React.useRef(null); + const [retryCountdown, setRetryCountdown] = React.useState(null); + + React.useEffect(() => { + const next = retryInfo?.next; + if (!next || next <= 0) { + retryNextRef.current = null; + retryStartRef.current = null; + setRetryCountdown(null); + return; + } + + // Start a fresh countdown when next value or attempt changes + retryNextRef.current = next; + retryStartRef.current = Date.now(); + + const update = () => { + const elapsed = Date.now() - (retryStartRef.current ?? Date.now()); + const remaining = Math.max(0, next - elapsed); + setRetryCountdown(Math.ceil(remaining / 1000)); + }; + + update(); + const id = setInterval(update, 500); + return () => clearInterval(id); + }, [retryInfo?.next, retryInfo?.attempt]); + const clearTimers = React.useCallback(() => { if (processQueueTimerRef.current) { clearTimeout(processQueueTimerRef.current); @@ -61,6 +92,13 @@ export function WorkingPlaceholder({ return; } + // Retry state has its own display — skip the normal queue + if (retryInfo) { + clearTimers(); + queuedStatusRef.current = null; + return; + } + const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText; const incomingPermission = Boolean(isWaitingForPermission); const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission; @@ -96,6 +134,7 @@ export function WorkingPlaceholder({ statusText, isGenericStatus, isWaitingForPermission, + retryInfo, displayedText, displayedPermission, clearTimers, @@ -105,7 +144,33 @@ export function WorkingPlaceholder({ React.useEffect(() => () => clearTimers(), [clearTimers]); - if (!isWorking || !displayedText) { + if (!isWorking) { + return null; + } + + // Retry state: show countdown and attempt info + if (retryInfo) { + const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : ''; + const countdownLabel = retryCountdown !== null && retryCountdown > 0 ? ` in ${retryCountdown}s` : ''; + const retryText = `Retrying${countdownLabel}${attemptLabel}...`; + + return ( +
+ + + {retryText} + + +
+ ); + } + + if (!displayedText) { return null; } diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index a4ee7662..c5325fc6 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -675,6 +675,7 @@ export const SessionSidebar: React.FC = ({ const deviceInfo = useDeviceInfo(); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); + const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const gitDirectories = useGitStore((state) => state.directories); @@ -1810,7 +1811,10 @@ export const SessionSidebar: React.FC = ({ const hasChildren = node.children.length > 0; const isPinnedSession = pinnedSessionIds.has(session.id); const isExpanded = expandedParents.has(session.id); - const needsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; + const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); + const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; + // When notifyOnSubtasks is disabled, suppress attention dots for child sessions. + const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); const sessionSummary = session.summary as | { additions?: number | string | null; @@ -2162,6 +2166,7 @@ export const SessionSidebar: React.FC = ({ copiedSessionId, mobileVariant, openMenuSessionId, + notifyOnSubtasks, ], ); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 9ecb8ece..303587c4 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -28,6 +28,7 @@ interface WorkingSummary { abortActive: boolean; lastCompletionId: string | null; isComplete: boolean; + retryInfo: { attempt?: number; next?: number } | null; } interface FormingSummary { @@ -70,6 +71,7 @@ const DEFAULT_WORKING: WorkingSummary = { abortActive: false, lastCompletionId: null, isComplete: false, + retryInfo: null, }; const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant'; @@ -123,6 +125,18 @@ export function useAssistantStatus(): AssistantStatusSnapshot { const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity(); + const sessionRetryAttempt = useSessionStore((state) => { + if (!currentSessionId || !state.sessionStatus) return undefined; + const s = state.sessionStatus.get(currentSessionId); + return s?.type === 'retry' ? s.attempt : undefined; + }); + + const sessionRetryNext = useSessionStore((state) => { + if (!currentSessionId || !state.sessionStatus) return undefined; + const s = state.sessionStatus.get(currentSessionId); + return s?.type === 'retry' ? s.next : undefined; + }); + const sessionMessages = React.useMemo>(() => { if (!currentSessionId) { return []; @@ -293,12 +307,14 @@ export function useAssistantStatus(): AssistantStatusSnapshot { isCooldown: false, statusText: null, canAbort: false, + retryInfo: null, }; } const isWorking = isPhaseWorking; const isStreaming = activityPhase === 'busy'; const isCooldown = false; + const isRetry = activityPhase === 'retry'; let activity: AssistantActivity = 'idle'; if (isWorking) { @@ -309,6 +325,10 @@ export function useAssistantStatus(): AssistantStatusSnapshot { } } + const retryInfo = isRetry + ? { attempt: sessionRetryAttempt, next: sessionRetryNext } + : null; + return { activity, hasWorkingContext: isWorking, @@ -328,8 +348,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { abortActive: false, lastCompletionId: null, isComplete: false, + retryInfo, }; - }, [activityPhase, isPhaseWorking, parsedStatus, abortState]); + }, [activityPhase, isPhaseWorking, parsedStatus, abortState, sessionRetryAttempt, sessionRetryNext]); const forming = React.useMemo(() => { @@ -380,6 +401,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { statusText: 'waiting for permission', isWaitingForPermission: true, canAbort: false, + retryInfo: null, }; }, [currentSessionId, permissions, baseWorking]); diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index e761ddb9..0840ad5b 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -42,7 +42,6 @@ declare global { } } -const ENABLE_EMPTY_RESPONSE_DETECTION = false; const TEXT_SHRINK_TOLERANCE = 50; const RESYNC_DEBOUNCE_MS = 750; const QUESTION_RECONCILE_COOLDOWN_MS = 1500; @@ -109,6 +108,7 @@ export const useEventStream = () => { updateMessageInfo, updateSessionCompaction, addPermission, + dismissPermission, addQuestion, dismissQuestion, currentSessionId, @@ -357,7 +357,6 @@ export const useEventStream = () => { const unsubscribeRef = React.useRef<(() => void) | null>(null); const reconnectTimeoutRef = React.useRef(null); const reconnectAttemptsRef = React.useRef(0); - const emptyResponseToastShownRef = React.useRef>(new Set()); const missingMessageHydrationRef = React.useRef>(new Set()); const metadataRefreshTimestampsRef = React.useRef>(new Map()); const sessionRefreshTimeoutRef = React.useRef(null); @@ -1338,75 +1337,6 @@ export const useEventStream = () => { void saveSessionCursor(sessionId, messageId, timeCompleted); - if (ENABLE_EMPTY_RESPONSE_DETECTION) { - const completedMessage = getMessageFromStore(sessionId, messageId); - if (completedMessage) { - const storedParts = Array.isArray(completedMessage.parts) ? completedMessage.parts : []; - const eventParts = partsArray; - - const combinedParts: Part[] = [...storedParts]; - for (let i = 0; i < eventParts.length; i++) { - const rawPart = eventParts[i]; - if (!rawPart) continue; - - const normalized: Part = { - ...rawPart, - type: (rawPart as { type?: string }).type || 'text', - } as Part; - - const alreadyPresent = combinedParts.some( - (existing) => - existing.id === normalized.id && - existing.type === normalized.type && - (existing as { callID?: string }).callID === (normalized as { callID?: string }).callID - ); - - if (!alreadyPresent) { - combinedParts.push(normalized); - } - } - - let hasStepMarkers = false; - let hasTextContent = false; - let hasTools = false; - let hasReasoning = false; - let hasFiles = false; - - for (let i = 0; i < combinedParts.length; i++) { - const part = combinedParts[i]; - if (!part) continue; - - if (part.type === 'step-start' || part.type === 'step-finish') { - hasStepMarkers = true; - } else if (part.type === 'text') { - const text = (part as { text?: string }).text; - if (typeof text === 'string' && text.trim().length > 0) { - hasTextContent = true; - } - } else if (part.type === 'tool') { - hasTools = true; - } else if (part.type === 'reasoning') { - hasReasoning = true; - } else if (part.type === 'file') { - hasFiles = true; - } - } - - const hasMeaningfulContent = hasTextContent || hasTools || hasReasoning || hasFiles; - const isEmptyResponse = !hasMeaningfulContent && !hasStepMarkers; - - if (isEmptyResponse && !emptyResponseToastShownRef.current.has(messageId)) { - emptyResponseToastShownRef.current.add(messageId); - import('sonner').then(({ toast }) => { - toast.info('Assistant response was empty', { - description: 'Try sending your message again or rephrase it.', - duration: 5000, - }); - }); - } - } - } - completeStreamingMessage(sessionId, messageId); // Removed: void refreshSessionStatus(); @@ -1536,6 +1466,7 @@ export const useEventStream = () => { import('sonner').then(({ toast }) => { toast.warning('Permission required', { description: sessionTitle, + duration: Infinity, action: { label: 'Open', onClick: () => { @@ -1552,8 +1483,16 @@ export const useEventStream = () => { break; } - case 'permission.replied': + case 'permission.replied': { + const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; + const requestId = + typeof props.requestID === 'string' ? props.requestID : + typeof props.id === 'string' ? props.id : null; + if (sessionId && requestId) { + dismissPermission(sessionId, requestId); + } break; + } case 'question.asked': { if (!('sessionID' in props) || typeof props.sessionID !== 'string') { @@ -1598,6 +1537,7 @@ export const useEventStream = () => { import('sonner').then(({ toast }) => { toast.info('Input needed', { description: sessionTitle, + duration: Infinity, action: { label: 'Open', onClick: () => { @@ -1680,6 +1620,7 @@ export const useEventStream = () => { completeStreamingMessage, updateMessageInfo, addPermission, + dismissPermission, addQuestion, dismissQuestion, checkConnection, diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 1d7a5a5b..48f81698 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -14,6 +14,7 @@ interface PermissionState { interface PermissionActions { addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map, sessionAgentSelections?: Map, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void; respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; + dismissPermission: (sessionId: string, requestId: string) => void; } type PermissionStore = PermissionState & PermissionActions; @@ -123,6 +124,16 @@ export const usePermissionStore = create()( return { permissions: newPermissions }; }); }, + + dismissPermission: (sessionId: string, requestId: string) => { + set((state) => { + const sessionPermissions = state.permissions.get(sessionId) || []; + const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); + const newPermissions = new Map(state.permissions); + newPermissions.set(sessionId, updatedPermissions); + return { permissions: newPermissions }; + }); + }, }), { name: "permission-store", diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 457d5115..90727118 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -234,6 +234,7 @@ export interface SessionStore { updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void; addPermission: (permission: PermissionRequest) => void; respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; + dismissPermission: (sessionId: string, requestId: string) => void; addQuestion: (question: QuestionRequest) => void; dismissQuestion: (sessionId: string, requestId: string) => void; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 78df3343..781e9d07 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -526,6 +526,7 @@ export const useSessionStore = create()( return usePermissionStore.getState().addPermission(permission, contextData); }, respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response), + dismissPermission: (sessionId: string, requestId: string) => usePermissionStore.getState().dismissPermission(sessionId, requestId), addQuestion: (question: QuestionRequest) => useQuestionStore.getState().addQuestion(question), dismissQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().dismissQuestion(sessionId, requestId),