Spaces:
Running
Running
feat: add activity status bar above chat input
Browse filesShows real-time agent activity with a pulsing dot and label:
- Thinking (yellow) β waiting for LLM response
- Writing (blue) β streaming assistant text
- Running <tool> (green) β executing a tool
- Waiting for approval (yellow) β needs user decision
Backed by activityStatus in agentStore, fed by side-channel
callbacks from the WebSocket transport.
Co-authored-by: Cursor <cursoragent@cursor.com>
frontend/src/components/Chat/ActivityStatusBar.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Typography } from '@mui/material';
|
| 2 |
+
import { keyframes } from '@mui/system';
|
| 3 |
+
import { useAgentStore, type ActivityStatus } from '@/store/agentStore';
|
| 4 |
+
|
| 5 |
+
const pulse = keyframes`
|
| 6 |
+
0%, 100% { opacity: 1; }
|
| 7 |
+
50% { opacity: 0.4; }
|
| 8 |
+
`;
|
| 9 |
+
|
| 10 |
+
const TOOL_LABELS: Record<string, string> = {
|
| 11 |
+
hf_jobs: 'Running job',
|
| 12 |
+
hf_repo_files: 'Uploading file',
|
| 13 |
+
hf_repo_git: 'Git operation',
|
| 14 |
+
hf_inspect_dataset: 'Inspecting dataset',
|
| 15 |
+
hf_search: 'Searching',
|
| 16 |
+
plan_tool: 'Planning',
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function statusLabel(status: ActivityStatus): string {
|
| 20 |
+
switch (status.type) {
|
| 21 |
+
case 'thinking': return 'Thinking';
|
| 22 |
+
case 'streaming': return 'Writing';
|
| 23 |
+
case 'tool': return TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
|
| 24 |
+
case 'waiting-approval': return 'Waiting for approval';
|
| 25 |
+
default: return '';
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function statusDot(status: ActivityStatus): string {
|
| 30 |
+
switch (status.type) {
|
| 31 |
+
case 'thinking': return 'var(--accent-yellow)';
|
| 32 |
+
case 'streaming': return 'var(--accent-blue, #58a6ff)';
|
| 33 |
+
case 'tool': return 'var(--accent-green, #3fb950)';
|
| 34 |
+
case 'waiting-approval': return 'var(--accent-yellow)';
|
| 35 |
+
default: return 'transparent';
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export default function ActivityStatusBar() {
|
| 40 |
+
const activityStatus = useAgentStore(s => s.activityStatus);
|
| 41 |
+
|
| 42 |
+
if (activityStatus.type === 'idle') return null;
|
| 43 |
+
|
| 44 |
+
const label = statusLabel(activityStatus);
|
| 45 |
+
const dotColor = statusDot(activityStatus);
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<Box
|
| 49 |
+
sx={{
|
| 50 |
+
display: 'flex',
|
| 51 |
+
alignItems: 'center',
|
| 52 |
+
gap: 1,
|
| 53 |
+
px: 2,
|
| 54 |
+
py: 0.5,
|
| 55 |
+
minHeight: 28,
|
| 56 |
+
}}
|
| 57 |
+
>
|
| 58 |
+
<Box
|
| 59 |
+
sx={{
|
| 60 |
+
width: 6,
|
| 61 |
+
height: 6,
|
| 62 |
+
borderRadius: '50%',
|
| 63 |
+
bgcolor: dotColor,
|
| 64 |
+
animation: `${pulse} 1.5s ease-in-out infinite`,
|
| 65 |
+
flexShrink: 0,
|
| 66 |
+
}}
|
| 67 |
+
/>
|
| 68 |
+
<Typography
|
| 69 |
+
sx={{
|
| 70 |
+
fontFamily: 'monospace',
|
| 71 |
+
fontSize: '0.72rem',
|
| 72 |
+
color: 'var(--muted-text)',
|
| 73 |
+
letterSpacing: '0.02em',
|
| 74 |
+
animation: `${pulse} 1.5s ease-in-out infinite`,
|
| 75 |
+
}}
|
| 76 |
+
>
|
| 77 |
+
{label}β¦
|
| 78 |
+
</Typography>
|
| 79 |
+
</Box>
|
| 80 |
+
);
|
| 81 |
+
}
|
frontend/src/components/Layout/AppLayout.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { useAgentChat } from '@/hooks/useAgentChat';
|
|
| 25 |
import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
|
| 26 |
import CodePanel from '@/components/CodePanel/CodePanel';
|
| 27 |
import ChatInput from '@/components/Chat/ChatInput';
|
|
|
|
| 28 |
import MessageList from '@/components/Chat/MessageList';
|
| 29 |
import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
|
| 30 |
import { apiFetch } from '@/utils/api';
|
|
@@ -359,6 +360,7 @@ export default function AppLayout() {
|
|
| 359 |
{activeSessionId ? (
|
| 360 |
<>
|
| 361 |
<MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
|
|
|
|
| 362 |
<ChatInput
|
| 363 |
onSend={handleSendMessage}
|
| 364 |
onStop={stop}
|
|
|
|
| 25 |
import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
|
| 26 |
import CodePanel from '@/components/CodePanel/CodePanel';
|
| 27 |
import ChatInput from '@/components/Chat/ChatInput';
|
| 28 |
+
import ActivityStatusBar from '@/components/Chat/ActivityStatusBar';
|
| 29 |
import MessageList from '@/components/Chat/MessageList';
|
| 30 |
import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
|
| 31 |
import { apiFetch } from '@/utils/api';
|
|
|
|
| 360 |
{activeSessionId ? (
|
| 361 |
<>
|
| 362 |
<MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
|
| 363 |
+
<ActivityStatusBar />
|
| 364 |
<ChatInput
|
| 365 |
onSend={handleSendMessage}
|
| 366 |
onStop={stop}
|
frontend/src/hooks/useAgentChat.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
|
|
| 28 |
const {
|
| 29 |
setProcessing,
|
| 30 |
setConnected,
|
|
|
|
| 31 |
setError,
|
| 32 |
setPanelTab,
|
| 33 |
setActivePanelTab,
|
|
@@ -57,6 +58,7 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
|
|
| 57 |
},
|
| 58 |
onProcessing: () => {
|
| 59 |
setProcessing(true);
|
|
|
|
| 60 |
},
|
| 61 |
onProcessingDone: () => {
|
| 62 |
setProcessing(false);
|
|
@@ -110,6 +112,7 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
|
|
| 110 |
},
|
| 111 |
onApprovalRequired: (tools) => {
|
| 112 |
if (!tools.length) return;
|
|
|
|
| 113 |
const firstTool = tools[0];
|
| 114 |
const args = firstTool.arguments as Record<string, string | undefined>;
|
| 115 |
|
|
@@ -172,6 +175,12 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
|
|
| 172 |
if (!success) setActivePanelTab('output');
|
| 173 |
}
|
| 174 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}),
|
| 176 |
// Zustand setters are stable
|
| 177 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
| 28 |
const {
|
| 29 |
setProcessing,
|
| 30 |
setConnected,
|
| 31 |
+
setActivityStatus,
|
| 32 |
setError,
|
| 33 |
setPanelTab,
|
| 34 |
setActivePanelTab,
|
|
|
|
| 58 |
},
|
| 59 |
onProcessing: () => {
|
| 60 |
setProcessing(true);
|
| 61 |
+
setActivityStatus({ type: 'thinking' });
|
| 62 |
},
|
| 63 |
onProcessingDone: () => {
|
| 64 |
setProcessing(false);
|
|
|
|
| 112 |
},
|
| 113 |
onApprovalRequired: (tools) => {
|
| 114 |
if (!tools.length) return;
|
| 115 |
+
setActivityStatus({ type: 'waiting-approval' });
|
| 116 |
const firstTool = tools[0];
|
| 117 |
const args = firstTool.arguments as Record<string, string | undefined>;
|
| 118 |
|
|
|
|
| 175 |
if (!success) setActivePanelTab('output');
|
| 176 |
}
|
| 177 |
},
|
| 178 |
+
onStreaming: () => {
|
| 179 |
+
setActivityStatus({ type: 'streaming' });
|
| 180 |
+
},
|
| 181 |
+
onToolRunning: (toolName: string) => {
|
| 182 |
+
setActivityStatus({ type: 'tool', toolName });
|
| 183 |
+
},
|
| 184 |
}),
|
| 185 |
// Zustand setters are stable
|
| 186 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
frontend/src/lib/ws-chat-transport.ts
CHANGED
|
@@ -31,6 +31,10 @@ export interface SideChannelCallbacks {
|
|
| 31 |
onToolCallPanel: (tool: string, args: Record<string, unknown>) => void;
|
| 32 |
/** Called when tool_output arrives with panel-relevant data */
|
| 33 |
onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
// ---------------------------------------------------------------------------
|
|
@@ -414,6 +418,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
|
| 414 |
if (!this.textPartId) {
|
| 415 |
this.textPartId = nextPartId('text');
|
| 416 |
this.enqueue({ type: 'text-start', id: this.textPartId });
|
|
|
|
| 417 |
}
|
| 418 |
this.enqueue({ type: 'text-delta', id: this.textPartId, delta });
|
| 419 |
break;
|
|
@@ -445,6 +450,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
|
| 445 |
this.enqueue({ type: 'tool-input-start', toolCallId, toolName, dynamic: true });
|
| 446 |
this.enqueue({ type: 'tool-input-available', toolCallId, toolName, input: args, dynamic: true });
|
| 447 |
|
|
|
|
| 448 |
this.sideChannel.onToolCallPanel(toolName, args as Record<string, unknown>);
|
| 449 |
break;
|
| 450 |
}
|
|
|
|
| 31 |
onToolCallPanel: (tool: string, args: Record<string, unknown>) => void;
|
| 32 |
/** Called when tool_output arrives with panel-relevant data */
|
| 33 |
onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
|
| 34 |
+
/** Called when assistant text starts streaming */
|
| 35 |
+
onStreaming: () => void;
|
| 36 |
+
/** Called when a tool starts running (non-plan) */
|
| 37 |
+
onToolRunning: (toolName: string) => void;
|
| 38 |
}
|
| 39 |
|
| 40 |
// ---------------------------------------------------------------------------
|
|
|
|
| 418 |
if (!this.textPartId) {
|
| 419 |
this.textPartId = nextPartId('text');
|
| 420 |
this.enqueue({ type: 'text-start', id: this.textPartId });
|
| 421 |
+
this.sideChannel.onStreaming();
|
| 422 |
}
|
| 423 |
this.enqueue({ type: 'text-delta', id: this.textPartId, delta });
|
| 424 |
break;
|
|
|
|
| 450 |
this.enqueue({ type: 'tool-input-start', toolCallId, toolName, dynamic: true });
|
| 451 |
this.enqueue({ type: 'tool-input-available', toolCallId, toolName, input: args, dynamic: true });
|
| 452 |
|
| 453 |
+
this.sideChannel.onToolRunning(toolName);
|
| 454 |
this.sideChannel.onToolCallPanel(toolName, args as Record<string, unknown>);
|
| 455 |
break;
|
| 456 |
}
|
frontend/src/store/agentStore.ts
CHANGED
|
@@ -32,10 +32,18 @@ export interface LLMHealthError {
|
|
| 32 |
model: string;
|
| 33 |
}
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
interface AgentStore {
|
| 36 |
// Global UI flags
|
| 37 |
isProcessing: boolean;
|
| 38 |
isConnected: boolean;
|
|
|
|
| 39 |
user: User | null;
|
| 40 |
error: string | null;
|
| 41 |
llmHealthError: LLMHealthError | null;
|
|
@@ -54,6 +62,7 @@ interface AgentStore {
|
|
| 54 |
// Actions
|
| 55 |
setProcessing: (isProcessing: boolean) => void;
|
| 56 |
setConnected: (isConnected: boolean) => void;
|
|
|
|
| 57 |
setUser: (user: User | null) => void;
|
| 58 |
setError: (error: string | null) => void;
|
| 59 |
setLlmHealthError: (error: LLMHealthError | null) => void;
|
|
@@ -76,6 +85,7 @@ interface AgentStore {
|
|
| 76 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
| 77 |
isProcessing: false,
|
| 78 |
isConnected: false,
|
|
|
|
| 79 |
user: null,
|
| 80 |
error: null,
|
| 81 |
llmHealthError: null,
|
|
@@ -90,8 +100,9 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 90 |
|
| 91 |
// ββ Global flags ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
|
| 93 |
-
setProcessing: (isProcessing) => set({ isProcessing }),
|
| 94 |
setConnected: (isConnected) => set({ isConnected }),
|
|
|
|
| 95 |
setUser: (user) => set({ user }),
|
| 96 |
setError: (error) => set({ error }),
|
| 97 |
setLlmHealthError: (error) => set({ llmHealthError: error }),
|
|
|
|
| 32 |
model: string;
|
| 33 |
}
|
| 34 |
|
| 35 |
+
export type ActivityStatus =
|
| 36 |
+
| { type: 'idle' }
|
| 37 |
+
| { type: 'thinking' }
|
| 38 |
+
| { type: 'tool'; toolName: string }
|
| 39 |
+
| { type: 'waiting-approval' }
|
| 40 |
+
| { type: 'streaming' };
|
| 41 |
+
|
| 42 |
interface AgentStore {
|
| 43 |
// Global UI flags
|
| 44 |
isProcessing: boolean;
|
| 45 |
isConnected: boolean;
|
| 46 |
+
activityStatus: ActivityStatus;
|
| 47 |
user: User | null;
|
| 48 |
error: string | null;
|
| 49 |
llmHealthError: LLMHealthError | null;
|
|
|
|
| 62 |
// Actions
|
| 63 |
setProcessing: (isProcessing: boolean) => void;
|
| 64 |
setConnected: (isConnected: boolean) => void;
|
| 65 |
+
setActivityStatus: (status: ActivityStatus) => void;
|
| 66 |
setUser: (user: User | null) => void;
|
| 67 |
setError: (error: string | null) => void;
|
| 68 |
setLlmHealthError: (error: LLMHealthError | null) => void;
|
|
|
|
| 85 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
| 86 |
isProcessing: false,
|
| 87 |
isConnected: false,
|
| 88 |
+
activityStatus: { type: 'idle' },
|
| 89 |
user: null,
|
| 90 |
error: null,
|
| 91 |
llmHealthError: null,
|
|
|
|
| 100 |
|
| 101 |
// ββ Global flags ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
|
| 103 |
+
setProcessing: (isProcessing) => set({ isProcessing, ...(!isProcessing ? { activityStatus: { type: 'idle' } } : {}) }),
|
| 104 |
setConnected: (isConnected) => set({ isConnected }),
|
| 105 |
+
setActivityStatus: (status) => set({ activityStatus: status }),
|
| 106 |
setUser: (user) => set({ user }),
|
| 107 |
setError: (error) => set({ error }),
|
| 108 |
setLlmHealthError: (error) => set({ llmHealthError: error }),
|