feat: inline activity monitor (status+elapsed+needs-input) & input.required notification
This commit is contained in:
parent
9c2ac65635
commit
8464b33de0
10 changed files with 120 additions and 19 deletions
|
|
@ -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 (!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 };
|
||||
}
|
||||
|
||||
if (!requiresInteraction) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export type SidebarProps = {
|
|||
onCloseSettings: () => void;
|
||||
isMobile: boolean;
|
||||
processingSessions?: Set<string>;
|
||||
processingStartTimes?: Map<string, number>;
|
||||
needsInputSessions?: Set<string>;
|
||||
};
|
||||
|
||||
export type SessionViewModel = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ type SidebarProjectItemProps = {
|
|||
isLoadingMoreSessions: boolean;
|
||||
currentTime: Date;
|
||||
processingSessions?: Set<string>;
|
||||
processingStartTimes?: Map<string, number>;
|
||||
needsInputSessions?: Set<string>;
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type SidebarProjectListProps = {
|
|||
initialSessionsLoaded: Set<string>;
|
||||
currentTime: Date;
|
||||
processingSessions?: Set<string>;
|
||||
processingStartTimes?: Map<string, number>;
|
||||
needsInputSessions?: Set<string>;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
deletingProjects: Set<string>;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ type SidebarProjectSessionsProps = {
|
|||
isLoadingMoreSessions: boolean;
|
||||
currentTime: Date;
|
||||
processingSessions?: Set<string>;
|
||||
processingStartTimes?: Map<string, number>;
|
||||
needsInputSessions?: Set<string>;
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type SidebarSessionItemProps = {
|
|||
selectedSession: ProjectSession | null;
|
||||
currentTime: Date;
|
||||
processingSessions?: Set<string>;
|
||||
processingStartTimes?: Map<string, number>;
|
||||
needsInputSessions?: Set<string>;
|
||||
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 (
|
||||
<div className="group relative">
|
||||
{isProcessing ? (
|
||||
{isNeedsInput ? (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
|
||||
title={t('sessions.needsInput', { defaultValue: 'Needs your input' })}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rose-500" />
|
||||
</span>
|
||||
</div>
|
||||
) : isProcessing ? (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
|
||||
title={t('sessions.agentRunning', { defaultValue: 'Agent running' })}
|
||||
|
|
@ -123,7 +156,9 @@ export default function SidebarSessionItem({
|
|||
className={cn(
|
||||
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
||||
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
||||
!isSelected && isProcessing
|
||||
!isSelected && isNeedsInput
|
||||
? 'border-rose-500/40 bg-rose-50/10 dark:bg-rose-900/10'
|
||||
: !isSelected && isProcessing
|
||||
? 'border-amber-500/40 bg-amber-50/10 dark:bg-amber-900/10'
|
||||
: !isSelected && isRecentlyActive
|
||||
? 'border-sky-500/20'
|
||||
|
|
@ -144,9 +179,11 @@ export default function SidebarSessionItem({
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
{activityLabel ? (
|
||||
<span className={`ml-auto flex-shrink-0 text-[11px] font-medium ${activityClass}`}>{activityLabel}</span>
|
||||
) : compactSessionAge ? (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center">
|
||||
{sessionView.messageCount > 0 && (
|
||||
|
|
@ -186,11 +223,13 @@ export default function SidebarSessionItem({
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
{activityLabel ? (
|
||||
<span className={`ml-auto flex-shrink-0 text-[11px] font-medium ${activityClass}`}>{activityLabel}</span>
|
||||
) : compactSessionAge ? (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||
{compactSessionAge}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center">
|
||||
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type UseProjectsStateArgs = {
|
|||
isMobile: boolean;
|
||||
activeSessions: Set<string>;
|
||||
processingSessions: Set<string>;
|
||||
processingStartTimes: Map<string, number>;
|
||||
needsInputSessions: Set<string>;
|
||||
};
|
||||
|
||||
type FetchProjectsOptions = {
|
||||
|
|
@ -243,6 +245,8 @@ export function useProjectsState({
|
|||
isMobile,
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
processingStartTimes,
|
||||
needsInputSessions,
|
||||
}: UseProjectsStateArgs) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue