tfrere HF Staff Cursor commited on
Commit
e024c8d
Β·
1 Parent(s): af99b8d

feat: add activity status bar above chat input

Browse files

Shows 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 }),