diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 0442b81..e92e4f0 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -535,13 +535,13 @@ async function queryClaudeSDK(command, options = {}, ws) { sdkOptions.canUseTool = async (toolName, input, context) => { const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); - // Dangerously-skip / bypass mode: auto-allow EVERY tool, including interactive - // ones (AskUserQuestion, ExitPlanMode). This guarantees zero prompts. - if (sdkOptions.permissionMode === 'bypassPermissions') { - return { behavior: 'allow', updatedInput: input }; - } - if (!requiresInteraction) { + // Bypass / dangerously-skip: auto-allow normal tools (no prompt). Interactive + // tools (AskUserQuestion, ExitPlanMode) are NOT bypassed - they are genuine + // requests for user input and must still reach the user and fire a notification. + if (sdkOptions.permissionMode === 'bypassPermissions') { + return { behavior: 'allow', updatedInput: input }; + } const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => matchesToolPermission(entry, toolName, input) ); @@ -563,7 +563,7 @@ async function queryClaudeSDK(command, options = {}, ws) { provider: 'claude', sessionId: capturedSessionId || sessionId || null, kind: 'action_required', - code: 'permission.required', + code: requiresInteraction ? 'input.required' : 'permission.required', meta: { toolName, sessionName: sessionSummary }, severity: 'warning', requiresUserAction: true, diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index 43a7d05..bf9e763 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -116,6 +116,7 @@ function buildPushBody(event) { 'permission.required': event.meta?.toolName ? `Action Required: Tool "${event.meta.toolName}" needs approval` : 'Action Required: A tool needs your approval', + 'input.required': 'Needs your input: Claude is waiting for an answer', 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 3b3ac8c..4f13248 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -34,6 +34,10 @@ function AppContentInner() { markSessionAsInactive, markSessionAsProcessing, markSessionAsNotProcessing, + processingStartTimes, + needsInputSessions, + markSessionAsNeedsInput, + clearSessionNeedsInput, } = useSessionProtection(); const { @@ -59,8 +63,33 @@ function AppContentInner() { isMobile, activeSessions, processingSessions, + processingStartTimes, + needsInputSessions, }); + // Observe the shared websocket stream at the app level so the sidebar can show + // a "needs input" state for ANY session (not just the open one). A pending + // permission / AskUserQuestion marks the session; resolution clears it. + useEffect(() => { + const msg = latestMessage as { kind?: string; sessionId?: string | null } | null; + if (!msg || !msg.kind) { + return; + } + const sid = msg.sessionId || null; + if (!sid) { + return; + } + if (msg.kind === 'permission_request') { + markSessionAsNeedsInput(sid); + } else if ( + msg.kind === 'permission_cancelled' || + msg.kind === 'complete' || + msg.kind === 'error' + ) { + clearSessionNeedsInput(sid); + } + }, [latestMessage, markSessionAsNeedsInput, clearSessionNeedsInput]); + usePaletteOpsRegister({ openSettings, refreshProjects: refreshProjectsSilently, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 56d6134..536a895 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -57,6 +57,8 @@ export type SidebarProps = { onCloseSettings: () => void; isMobile: boolean; processingSessions?: Set; + processingStartTimes?: Map; + needsInputSessions?: Set; }; export type SessionViewModel = { diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index aa3105e..5307227 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -40,6 +40,8 @@ function Sidebar({ onCloseSettings, isMobile, processingSessions, + processingStartTimes, + needsInputSessions, }: SidebarProps) { const { t } = useTranslation(['sidebar', 'common']); const { isPWA } = useDeviceSettings({ trackMobile: false }); @@ -154,6 +156,8 @@ function Sidebar({ initialSessionsLoaded, currentTime, processingSessions, + processingStartTimes, + needsInputSessions, editingSession, editingSessionName, deletingProjects, diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx index 022072a..89354c1 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx @@ -24,6 +24,8 @@ type SidebarProjectItemProps = { isLoadingMoreSessions: boolean; currentTime: Date; processingSessions?: Set; + processingStartTimes?: Map; + needsInputSessions?: Set; editingSession: string | null; editingSessionName: string; tasksEnabled: boolean; @@ -71,6 +73,8 @@ export default function SidebarProjectItem({ isLoadingMoreSessions, currentTime, processingSessions, + processingStartTimes, + needsInputSessions, editingSession, editingSessionName, tasksEnabled, @@ -415,6 +419,8 @@ export default function SidebarProjectItem({ isLoadingMoreSessions={isLoadingMoreSessions} currentTime={currentTime} processingSessions={processingSessions} + processingStartTimes={processingStartTimes} + needsInputSessions={needsInputSessions} editingSession={editingSession} editingSessionName={editingSessionName} onEditingSessionNameChange={onEditingSessionNameChange} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx index 8136c3b..65f9647 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectList.tsx @@ -20,6 +20,8 @@ export type SidebarProjectListProps = { initialSessionsLoaded: Set; currentTime: Date; processingSessions?: Set; + processingStartTimes?: Map; + needsInputSessions?: Set; editingSession: string | null; editingSessionName: string; deletingProjects: Set; @@ -65,6 +67,8 @@ export default function SidebarProjectList({ initialSessionsLoaded, currentTime, processingSessions, + processingStartTimes, + needsInputSessions, editingSession, editingSessionName, deletingProjects, @@ -134,6 +138,8 @@ export default function SidebarProjectList({ isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)} currentTime={currentTime} processingSessions={processingSessions} + processingStartTimes={processingStartTimes} + needsInputSessions={needsInputSessions} editingSession={editingSession} editingSessionName={editingSessionName} tasksEnabled={tasksEnabled} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx index 9cefabc..8f1ffcd 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx @@ -17,6 +17,8 @@ type SidebarProjectSessionsProps = { isLoadingMoreSessions: boolean; currentTime: Date; processingSessions?: Set; + processingStartTimes?: Map; + needsInputSessions?: Set; editingSession: string | null; editingSessionName: string; onEditingSessionNameChange: (value: string) => void; @@ -64,6 +66,8 @@ export default function SidebarProjectSessions({ isLoadingMoreSessions, currentTime, processingSessions, + processingStartTimes, + needsInputSessions, editingSession, editingSessionName, onEditingSessionNameChange, @@ -124,6 +128,8 @@ export default function SidebarProjectSessions({ selectedSession={selectedSession} currentTime={currentTime} processingSessions={processingSessions} + processingStartTimes={processingStartTimes} + needsInputSessions={needsInputSessions} editingSession={editingSession} editingSessionName={editingSessionName} onEditingSessionNameChange={onEditingSessionNameChange} diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 25ca139..7fffaa7 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -14,6 +14,8 @@ type SidebarSessionItemProps = { selectedSession: ProjectSession | null; currentTime: Date; processingSessions?: Set; + processingStartTimes?: Map; + needsInputSessions?: Set; editingSession: string | null; editingSessionName: string; onEditingSessionNameChange: (value: string) => void; @@ -59,12 +61,25 @@ const formatCompactSessionAge = (dateString: string, currentTime: Date): string return `${diffInDays}d`; }; +/** Live elapsed for a running session: Xs, then Mm Ss. */ +const formatElapsed = (ms: number): string => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}m ${s.toString().padStart(2, '0')}s`; +}; + export default function SidebarSessionItem({ project, session, selectedSession, currentTime, processingSessions, + processingStartTimes, + needsInputSessions, editingSession, editingSessionName, onEditingSessionNameChange, @@ -78,8 +93,16 @@ export default function SidebarSessionItem({ }: SidebarSessionItemProps) { const sessionView = createSessionViewModel(session, currentTime, t); const isSelected = selectedSession?.id === session.id; - const isProcessing = processingSessions?.has(session.id) ?? false; - const isRecentlyActive = sessionView.isActive && !isProcessing; + const isNeedsInput = needsInputSessions?.has(session.id) ?? false; + const isProcessing = (processingSessions?.has(session.id) ?? false) && !isNeedsInput; + const isRecentlyActive = sessionView.isActive && !isProcessing && !isNeedsInput; + const processingStart = processingStartTimes?.get(session.id); + const elapsedLabel = + (isProcessing || isNeedsInput) && processingStart + ? formatElapsed(currentTime.getTime() - processingStart) + : ''; + const activityLabel = isNeedsInput ? 'Needs input' : isProcessing ? elapsedLabel : ''; + const activityClass = isNeedsInput ? 'text-rose-400' : 'text-amber-400'; const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); // Sessions are owned by a project identified by `projectId` (DB primary key) @@ -99,7 +122,17 @@ export default function SidebarSessionItem({ return (
- {isProcessing ? ( + {isNeedsInput ? ( +
+ + + + +
+ ) : isProcessing ? (
@@ -144,9 +179,11 @@ export default function SidebarSessionItem({
{sessionView.sessionName}
- {compactSessionAge && ( + {activityLabel ? ( + {activityLabel} + ) : compactSessionAge ? ( {compactSessionAge} - )} + ) : null}
{sessionView.messageCount > 0 && ( @@ -186,11 +223,13 @@ export default function SidebarSessionItem({
{sessionView.sessionName}
- {compactSessionAge && ( + {activityLabel ? ( + {activityLabel} + ) : compactSessionAge ? ( {compactSessionAge} - )} + ) : null}
{sessionView.messageCount > 0 && {sessionView.messageCount}} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 92a7704..32be29d 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -19,6 +19,8 @@ type UseProjectsStateArgs = { isMobile: boolean; activeSessions: Set; processingSessions: Set; + processingStartTimes: Map; + needsInputSessions: Set; }; type FetchProjectsOptions = { @@ -243,6 +245,8 @@ export function useProjectsState({ isMobile, activeSessions, processingSessions, + processingStartTimes, + needsInputSessions, }: UseProjectsStateArgs) { const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); @@ -833,6 +837,8 @@ export function useProjectsState({ onCloseSettings: () => setShowSettings(false), isMobile, processingSessions, + processingStartTimes, + needsInputSessions, }), [ handleNewSession, @@ -851,6 +857,8 @@ export function useProjectsState({ selectedSession, showSettings, processingSessions, + processingStartTimes, + needsInputSessions, ], );