akseljoonas HF Staff commited on
Commit
d956620
Β·
verified Β·
1 Parent(s): 1ba32b8

Latest ui fixes

Browse files

What's in here
- put in stop button while loading
- made rejected be different than error
- output of the currently running tool is automatically shown in the righthand console, unless user intentionally cliks and 'locks' a tool, then the view stays on that tool until unlocked

frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
  import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
5
- import CloseIcon from '@mui/icons-material/Close';
6
  import { apiFetch } from '@/utils/api';
7
 
8
  // Model configuration
@@ -67,7 +67,6 @@ interface ChatInputProps {
67
 
68
  export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
69
  const [input, setInput] = useState('');
70
- const [stopHovered, setStopHovered] = useState(false);
71
  const inputRef = useRef<HTMLTextAreaElement>(null);
72
  const [selectedModelId, setSelectedModelId] = useState<string>(() => {
73
  try {
@@ -207,20 +206,23 @@ export default function ChatInput({ onSend, onStop, isProcessing = false, disabl
207
  {isProcessing ? (
208
  <IconButton
209
  onClick={onStop}
210
- onMouseEnter={() => setStopHovered(true)}
211
- onMouseLeave={() => setStopHovered(false)}
212
  sx={{
213
  mt: 1,
214
- p: 1,
215
  borderRadius: '10px',
216
- color: stopHovered ? 'var(--accent-yellow)' : 'var(--muted-text)',
217
  transition: 'all 0.2s',
 
218
  '&:hover': {
219
  bgcolor: 'var(--hover-bg)',
 
220
  },
221
  }}
222
  >
223
- {stopHovered ? <CloseIcon fontSize="small" /> : <CircularProgress size={20} color="inherit" />}
 
 
 
224
  </IconButton>
225
  ) : (
226
  <IconButton
 
2
  import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
5
+ import StopIcon from '@mui/icons-material/Stop';
6
  import { apiFetch } from '@/utils/api';
7
 
8
  // Model configuration
 
67
 
68
  export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
69
  const [input, setInput] = useState('');
 
70
  const inputRef = useRef<HTMLTextAreaElement>(null);
71
  const [selectedModelId, setSelectedModelId] = useState<string>(() => {
72
  try {
 
206
  {isProcessing ? (
207
  <IconButton
208
  onClick={onStop}
 
 
209
  sx={{
210
  mt: 1,
211
+ p: 1.5,
212
  borderRadius: '10px',
213
+ color: 'var(--muted-text)',
214
  transition: 'all 0.2s',
215
+ position: 'relative',
216
  '&:hover': {
217
  bgcolor: 'var(--hover-bg)',
218
+ color: 'var(--accent-red)',
219
  },
220
  }}
221
  >
222
+ <Box sx={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
223
+ <CircularProgress size={28} thickness={3} sx={{ color: 'inherit', position: 'absolute' }} />
224
+ <StopIcon sx={{ fontSize: 16 }} />
225
+ </Box>
226
  </IconButton>
227
  ) : (
228
  <IconButton
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -236,8 +236,8 @@ function costLabel(hardware: string): string | null {
236
  // Visual helpers
237
  // ---------------------------------------------------------------------------
238
 
239
- function StatusIcon({ state, cancelled }: { state: ToolPartState; cancelled?: boolean }) {
240
- if (cancelled) {
241
  return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
242
  }
243
  switch (state) {
@@ -501,7 +501,7 @@ function InlineApproval({
501
  // ---------------------------------------------------------------------------
502
 
503
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
504
- const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError } = useAgentStore();
505
  const researchSteps = useAgentStore(s => {
506
  const activeId = s.activeSessionId;
507
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
@@ -527,6 +527,9 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
527
  // Track which toolCallIds we've already submitted so we can detect new approval rounds
528
  const submittedIdsRef = useRef<Set<string>>(new Set());
529
 
 
 
 
530
  // Reset submission state when new (unseen) pending tools arrive β€” e.g. second approval round
531
  useEffect(() => {
532
  if (!isSubmitting || pendingTools.length === 0) return;
@@ -602,6 +605,10 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
602
  if (editedScript) {
603
  logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
604
  }
 
 
 
 
605
  return {
606
  tool_call_id: toolCallId,
607
  approved: d.approved,
@@ -621,7 +628,7 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
621
  setIsSubmitting(false);
622
  }
623
  },
624
- [approveTools, lockPanel, getEditedScript],
625
  );
626
 
627
  const handleApproveAll = useCallback(() => {
@@ -657,8 +664,8 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
657
  });
658
  }, []);
659
 
660
- // ── Panel click handler ───────────────────────────────────────────
661
- const handleClick = useCallback(
662
  (tool: DynamicToolPart) => {
663
  const args = tool.input as Record<string, unknown> | undefined;
664
  const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
@@ -684,7 +691,13 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
684
  const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
685
 
686
  const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
687
- if ((tool.state === 'output-available' || tool.state === 'output-error') && outputText) {
 
 
 
 
 
 
688
  let language = 'text';
689
  const content = String(outputText);
690
  if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
@@ -696,6 +709,15 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
696
  const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
697
  setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
698
  setRightPanelOpen(true);
 
 
 
 
 
 
 
 
 
699
  } else if (args) {
700
  const runningMessages = [
701
  'Crunching numbers and herding tensors...',
@@ -715,6 +737,51 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
715
  [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
716
  );
717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  // ── Parse hf_jobs metadata from output ────────────────────────────
719
  function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
720
  if (typeof output !== 'string') return {};
@@ -808,7 +875,7 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
808
  const cancelled = isCancelledTool(tool);
809
  const currentlyHasError = state === 'output-error';
810
  const persistedError = getToolError(tool.toolCallId);
811
- const hasError = persistedError || currentlyHasError;
812
 
813
  // Stale in-progress tools after page reload: treat as completed
814
  const stale = !isProcessing && (state === 'input-available' || state === 'input-streaming');
@@ -816,7 +883,10 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
816
  : isPending && localDecision
817
  ? (localDecision.approved ? 'input-available' : 'output-denied')
818
  : state;
 
 
819
  const label = cancelled ? 'cancelled'
 
820
  : hasError ? 'error'
821
  : statusLabel(displayState as ToolPartState);
822
 
@@ -853,11 +923,14 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
853
  py: 1,
854
  cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
855
  transition: 'background-color 0.15s',
 
 
856
  '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
857
  }}
858
  >
859
  <StatusIcon
860
  cancelled={cancelled}
 
861
  state={
862
  hasError
863
  ? 'output-error'
@@ -895,6 +968,7 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
895
  : null;
896
  const chipLabel = researchLabel || label;
897
  if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
 
898
  return (
899
  <Chip
900
  label={chipLabel}
@@ -903,12 +977,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
903
  height: 20,
904
  fontSize: '0.65rem',
905
  fontWeight: 600,
906
- bgcolor: cancelled ? 'rgba(255,255,255,0.05)'
907
  : hasError ? 'rgba(224,90,79,0.12)'
908
- : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
909
  : (researchLabel && displayState === 'output-available') ? 'rgba(47,204,113,0.12)'
910
  : 'var(--accent-yellow-weak)',
911
- color: cancelled ? 'var(--muted-text)'
912
  : hasError ? 'var(--accent-red)'
913
  : statusColor(displayState as ToolPartState),
914
  letterSpacing: '0.03em',
 
236
  // Visual helpers
237
  // ---------------------------------------------------------------------------
238
 
239
+ function StatusIcon({ state, cancelled, isRejected }: { state: ToolPartState; cancelled?: boolean; isRejected?: boolean }) {
240
+ if (cancelled || isRejected) {
241
  return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
242
  }
243
  switch (state) {
 
501
  // ---------------------------------------------------------------------------
502
 
503
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
504
+ const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError, setToolRejected, getToolRejected } = useAgentStore();
505
  const researchSteps = useAgentStore(s => {
506
  const activeId = s.activeSessionId;
507
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
 
527
  // Track which toolCallIds we've already submitted so we can detect new approval rounds
528
  const submittedIdsRef = useRef<Set<string>>(new Set());
529
 
530
+ // ── Panel lock state (for auto-follow vs user-selected) ───────────
531
+ const [lockedToolId, setLockedToolId] = useState<string | null>(null);
532
+
533
  // Reset submission state when new (unseen) pending tools arrive β€” e.g. second approval round
534
  useEffect(() => {
535
  if (!isSubmitting || pendingTools.length === 0) return;
 
605
  if (editedScript) {
606
  logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
607
  }
608
+ // Mark tool as rejected if not approved
609
+ if (!d.approved) {
610
+ setToolRejected(toolCallId, true);
611
+ }
612
  return {
613
  tool_call_id: toolCallId,
614
  approved: d.approved,
 
628
  setIsSubmitting(false);
629
  }
630
  },
631
+ [approveTools, lockPanel, getEditedScript, setToolRejected],
632
  );
633
 
634
  const handleApproveAll = useCallback(() => {
 
664
  });
665
  }, []);
666
 
667
+ // ── Show tool panel (shared logic) ────────────────────────────────
668
+ const showToolPanel = useCallback(
669
  (tool: DynamicToolPart) => {
670
  const args = tool.input as Record<string, unknown> | undefined;
671
  const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
 
691
  const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
692
 
693
  const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
694
+
695
+ // Determine if tool is still running or has completed
696
+ const isRunning = tool.state === 'input-available' || tool.state === 'input-streaming' || tool.state === 'approval-responded';
697
+ const hasCompleted = tool.state === 'output-available' || tool.state === 'output-error' || tool.state === 'output-denied';
698
+
699
+ if (outputText) {
700
+ // Tool has output - show it (regardless of state)
701
  let language = 'text';
702
  const content = String(outputText);
703
  if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
 
709
  const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
710
  setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
711
  setRightPanelOpen(true);
712
+ } else if (hasCompleted && args) {
713
+ // Tool completed but has no output - show input as fallback
714
+ setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output');
715
+ setRightPanelOpen(true);
716
+ } else if (isRunning && args) {
717
+ // Tool is still running - show running message
718
+ const content = `Tool \`${tool.toolName}\` is still running...\n\nClick the input tab to view the tool arguments.`;
719
+ setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
720
+ setRightPanelOpen(true);
721
  } else if (args) {
722
  const runningMessages = [
723
  'Crunching numbers and herding tensors...',
 
737
  [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
738
  );
739
 
740
+ // ── Panel click handler ───────────────────────────────────────────
741
+ const handleClick = useCallback(
742
+ (tool: DynamicToolPart) => {
743
+ // Toggle lock: if clicking the same tool that's already locked, unlock it
744
+ if (lockedToolId === tool.toolCallId) {
745
+ setLockedToolId(null);
746
+ return;
747
+ }
748
+
749
+ // Lock this tool
750
+ setLockedToolId(tool.toolCallId);
751
+
752
+ // Show the panel
753
+ showToolPanel(tool);
754
+ },
755
+ [lockedToolId, showToolPanel],
756
+ );
757
+
758
+ // ── Auto-follow currently active tool when not locked ─────────────
759
+ const activeToolIdRef = useRef<string | null>(null);
760
+
761
+ useEffect(() => {
762
+ if (lockedToolId !== null) return; // User has locked a tool, don't auto-follow
763
+
764
+ // Find the currently running tool (latest tool that's in progress)
765
+ const runningTool = tools.slice().reverse().find(t =>
766
+ t.state === 'input-available' ||
767
+ t.state === 'input-streaming' ||
768
+ t.state === 'approval-responded'
769
+ );
770
+
771
+ if (runningTool) {
772
+ // Track this as the active tool and show its panel
773
+ activeToolIdRef.current = runningTool.toolCallId;
774
+ showToolPanel(runningTool);
775
+ } else if (activeToolIdRef.current) {
776
+ // No running tool, but we were following one - check if it completed
777
+ const completedTool = tools.find(t => t.toolCallId === activeToolIdRef.current);
778
+ if (completedTool && (completedTool.state === 'output-available' || completedTool.state === 'output-error')) {
779
+ // The tool we were following has completed - update its panel
780
+ showToolPanel(completedTool);
781
+ }
782
+ }
783
+ }, [tools, lockedToolId, showToolPanel]);
784
+
785
  // ── Parse hf_jobs metadata from output ────────────────────────────
786
  function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
787
  if (typeof output !== 'string') return {};
 
875
  const cancelled = isCancelledTool(tool);
876
  const currentlyHasError = state === 'output-error';
877
  const persistedError = getToolError(tool.toolCallId);
878
+ const persistedRejection = getToolRejected(tool.toolCallId);
879
 
880
  // Stale in-progress tools after page reload: treat as completed
881
  const stale = !isProcessing && (state === 'input-available' || state === 'input-streaming');
 
883
  : isPending && localDecision
884
  ? (localDecision.approved ? 'input-available' : 'output-denied')
885
  : state;
886
+ const isRejected = displayState === 'output-denied' || persistedRejection;
887
+ const hasError = (persistedError || currentlyHasError) && !isRejected;
888
  const label = cancelled ? 'cancelled'
889
+ : isRejected ? 'rejected'
890
  : hasError ? 'error'
891
  : statusLabel(displayState as ToolPartState);
892
 
 
923
  py: 1,
924
  cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
925
  transition: 'background-color 0.15s',
926
+ bgcolor: lockedToolId === tool.toolCallId ? 'var(--hover-bg)' : 'transparent',
927
+ borderLeft: lockedToolId === tool.toolCallId ? '3px solid var(--accent-yellow)' : '3px solid transparent',
928
  '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
929
  }}
930
  >
931
  <StatusIcon
932
  cancelled={cancelled}
933
+ isRejected={isRejected}
934
  state={
935
  hasError
936
  ? 'output-error'
 
968
  : null;
969
  const chipLabel = researchLabel || label;
970
  if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
971
+
972
  return (
973
  <Chip
974
  label={chipLabel}
 
977
  height: 20,
978
  fontSize: '0.65rem',
979
  fontWeight: 600,
980
+ bgcolor: (cancelled || isRejected) ? 'rgba(255,255,255,0.05)'
981
  : hasError ? 'rgba(224,90,79,0.12)'
 
982
  : (researchLabel && displayState === 'output-available') ? 'rgba(47,204,113,0.12)'
983
  : 'var(--accent-yellow-weak)',
984
+ color: (cancelled || isRejected) ? 'var(--muted-text)'
985
  : hasError ? 'var(--accent-red)'
986
  : statusColor(displayState as ToolPartState),
987
  letterSpacing: '0.03em',
frontend/src/store/agentStore.ts CHANGED
@@ -111,6 +111,9 @@ interface AgentStore {
111
  // Tool error states (tool_call_id -> true if errored) - persisted across renders
112
  toolErrors: Record<string, boolean>;
113
 
 
 
 
114
  // ── Per-session actions ─────────────────────────────────────────────
115
 
116
  /** Update a session's state. If it's the active session, also update flat state. */
@@ -154,6 +157,9 @@ interface AgentStore {
154
 
155
  setToolError: (toolCallId: string, hasError: boolean) => void;
156
  getToolError: (toolCallId: string) => boolean | undefined;
 
 
 
157
  }
158
 
159
  /**
@@ -194,6 +200,25 @@ function saveToolErrors(errors: Record<string, boolean>): void {
194
  }
195
  }
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  export const useAgentStore = create<AgentStore>()((set, get) => ({
198
  sessionStates: {},
199
  activeSessionId: null,
@@ -215,6 +240,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
215
  jobUrls: {},
216
  jobStatuses: {},
217
  toolErrors: loadToolErrors(),
 
218
 
219
  // ── Per-session state management ──────────────────────────────────
220
 
@@ -409,4 +435,16 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
409
  },
410
 
411
  getToolError: (toolCallId) => get().toolErrors[toolCallId],
 
 
 
 
 
 
 
 
 
 
 
 
412
  }));
 
111
  // Tool error states (tool_call_id -> true if errored) - persisted across renders
112
  toolErrors: Record<string, boolean>;
113
 
114
+ // Tool rejected states (tool_call_id -> true if rejected by user) - persisted across renders
115
+ rejectedTools: Record<string, boolean>;
116
+
117
  // ── Per-session actions ─────────────────────────────────────────────
118
 
119
  /** Update a session's state. If it's the active session, also update flat state. */
 
157
 
158
  setToolError: (toolCallId: string, hasError: boolean) => void;
159
  getToolError: (toolCallId: string) => boolean | undefined;
160
+
161
+ setToolRejected: (toolCallId: string, isRejected: boolean) => void;
162
+ getToolRejected: (toolCallId: string) => boolean | undefined;
163
  }
164
 
165
  /**
 
200
  }
201
  }
202
 
203
+ // Load persisted rejected tools from localStorage
204
+ function loadRejectedTools(): Record<string, boolean> {
205
+ try {
206
+ const stored = localStorage.getItem('hf-agent-rejected-tools');
207
+ return stored ? JSON.parse(stored) : {};
208
+ } catch {
209
+ return {};
210
+ }
211
+ }
212
+
213
+ // Save rejected tools to localStorage
214
+ function saveRejectedTools(rejected: Record<string, boolean>): void {
215
+ try {
216
+ localStorage.setItem('hf-agent-rejected-tools', JSON.stringify(rejected));
217
+ } catch (e) {
218
+ console.warn('Failed to persist rejected tools:', e);
219
+ }
220
+ }
221
+
222
  export const useAgentStore = create<AgentStore>()((set, get) => ({
223
  sessionStates: {},
224
  activeSessionId: null,
 
240
  jobUrls: {},
241
  jobStatuses: {},
242
  toolErrors: loadToolErrors(),
243
+ rejectedTools: loadRejectedTools(),
244
 
245
  // ── Per-session state management ──────────────────────────────────
246
 
 
435
  },
436
 
437
  getToolError: (toolCallId) => get().toolErrors[toolCallId],
438
+
439
+ // ── Tool Rejections ──────────────────────────────────────────────────
440
+
441
+ setToolRejected: (toolCallId, isRejected) => {
442
+ set((state) => {
443
+ const updated = { ...state.rejectedTools, [toolCallId]: isRejected };
444
+ saveRejectedTools(updated);
445
+ return { rejectedTools: updated };
446
+ });
447
+ },
448
+
449
+ getToolRejected: (toolCallId) => get().rejectedTools[toolCallId],
450
  }));