Aksel Joonas Reedi commited on
Commit
25d8236
Β·
unverified Β·
1 Parent(s): 6131fc8

Remember the chosen jobs namespace and clean up the picker dialog (#162)

Browse files

When a user has paid orgs but no Pro subscription, hf_jobs needs an
explicit namespace. The picker fired on every tool call β€” even after
the user already chose one. Persist the selection in localStorage and
auto-attach it to subsequent hf_jobs approvals; if the saved namespace
becomes invalid (e.g. left the org), backend 400 surfaces a typed error
that clears the preference and reopens the picker.

UI is rewritten to match ClaudeCapDialog: tighter copy, single dropdown
without the redundant "Eligible namespaces" panel, "Continue" + "Skip
this tool call" actions.

backend/routes/agent.py CHANGED
@@ -195,6 +195,9 @@ async def _enforce_jobs_access_for_approvals(
195
  "The selected jobs namespace is not one of your eligible paid organizations. "
196
  f"Allowed namespaces: {', '.join(access.paid_org_names)}"
197
  ),
 
 
 
198
  },
199
  )
200
  missing_namespace = [
 
195
  "The selected jobs namespace is not one of your eligible paid organizations. "
196
  f"Allowed namespaces: {', '.join(access.paid_org_names)}"
197
  ),
198
+ "plan": user.get("plan", "free"),
199
+ "tool_call_ids": invalid_namespace,
200
+ "eligible_namespaces": access.paid_org_names,
201
  },
202
  )
203
  missing_namespace = [
frontend/src/components/JobsUpgradeDialog.tsx CHANGED
@@ -8,7 +8,6 @@ import {
8
  DialogContentText,
9
  DialogTitle,
10
  FormControl,
11
- InputLabel,
12
  MenuItem,
13
  Select,
14
  Typography,
@@ -37,13 +36,20 @@ export default function JobsUpgradeDialog({
37
  onClose,
38
  onContinueWithNamespace,
39
  }: JobsUpgradeDialogProps) {
40
- const [selectedNamespace, setSelectedNamespace] = useState('');
41
 
42
  useEffect(() => {
43
  if (!open) return;
44
  setSelectedNamespace(eligibleNamespaces[0] || '');
45
  }, [open, eligibleNamespaces]);
46
 
 
 
 
 
 
 
 
47
  return (
48
  <Dialog
49
  open={open}
@@ -57,7 +63,7 @@ export default function JobsUpgradeDialog({
57
  border: '1px solid var(--border)',
58
  borderRadius: 'var(--radius-md)',
59
  boxShadow: 'var(--shadow-1)',
60
- maxWidth: 500,
61
  mx: 2,
62
  },
63
  }}
@@ -65,72 +71,75 @@ export default function JobsUpgradeDialog({
65
  <DialogTitle
66
  sx={{ color: 'var(--text)', fontWeight: 700, fontSize: '1rem', pt: 2.5, pb: 0, px: 3 }}
67
  >
68
- {mode === 'namespace' ? 'Choose the org for this job' : 'Jobs need Pro or a paid org'}
69
  </DialogTitle>
70
  <DialogContent sx={{ px: 3, pt: 1.25, pb: 0 }}>
71
  <DialogContentText
72
  sx={{ color: 'var(--muted-text)', fontSize: '0.85rem', lineHeight: 1.6 }}
73
  >
74
- {message}
75
  </DialogContentText>
76
- {eligibleNamespaces.length > 0 && (
77
- <Box
78
- sx={{
79
- mt: 2,
80
- p: 1.5,
81
- borderRadius: '8px',
82
- bgcolor: 'var(--accent-yellow-weak)',
83
- border: '1px solid var(--border)',
84
- }}
85
- >
86
- <Typography
87
- variant="caption"
88
  sx={{
89
- display: 'block',
90
- fontWeight: 700,
91
  color: 'var(--text)',
92
- fontSize: '0.78rem',
93
- mb: 1,
94
- letterSpacing: '0.02em',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }}
96
  >
97
- Eligible namespaces
98
- </Typography>
99
- {mode === 'namespace' ? (
100
- <FormControl fullWidth size="small">
101
- <InputLabel id="jobs-namespace-label">Organization</InputLabel>
102
- <Select
103
- labelId="jobs-namespace-label"
104
- value={selectedNamespace}
105
- label="Organization"
106
- onChange={(e) => setSelectedNamespace(String(e.target.value))}
107
  >
108
- {eligibleNamespaces.map((namespace) => (
109
- <MenuItem key={namespace} value={namespace}>
110
- {namespace}
111
- </MenuItem>
112
- ))}
113
- </Select>
114
- </FormControl>
115
- ) : (
116
  <Typography
117
  variant="caption"
118
- sx={{ display: 'block', color: 'var(--muted-text)', fontSize: '0.78rem', lineHeight: 1.55 }}
119
  >
120
- {eligibleNamespaces.join(', ')}
121
  </Typography>
122
- )}
123
- </Box>
124
  )}
125
- <Typography
126
- variant="caption"
127
- sx={{ display: 'block', mt: 2, color: 'var(--muted-text)', fontSize: '0.78rem', lineHeight: 1.55 }}
128
- >
129
- If you decline, the agent will have to find another way forward without `hf_jobs`.
130
- </Typography>
131
  </DialogContent>
132
- <DialogActions sx={{ px: 3, pb: 2.5, pt: 2, gap: 1 }}>
133
- {mode === 'namespace' ? (
134
  <Button
135
  onClick={() => onContinueWithNamespace(selectedNamespace)}
136
  disabled={!selectedNamespace}
@@ -147,7 +156,7 @@ export default function JobsUpgradeDialog({
147
  '&:hover': { bgcolor: '#FFB340', boxShadow: 'none' },
148
  }}
149
  >
150
- Run under selected org
151
  </Button>
152
  ) : (
153
  <Button
@@ -183,7 +192,7 @@ export default function JobsUpgradeDialog({
183
  '&:hover': { bgcolor: 'var(--hover-bg)' },
184
  }}
185
  >
186
- Decline tool call
187
  </Button>
188
  </DialogActions>
189
  </Dialog>
 
8
  DialogContentText,
9
  DialogTitle,
10
  FormControl,
 
11
  MenuItem,
12
  Select,
13
  Typography,
 
36
  onClose,
37
  onContinueWithNamespace,
38
  }: JobsUpgradeDialogProps) {
39
+ const [selectedNamespace, setSelectedNamespace] = useState(() => eligibleNamespaces[0] || '');
40
 
41
  useEffect(() => {
42
  if (!open) return;
43
  setSelectedNamespace(eligibleNamespaces[0] || '');
44
  }, [open, eligibleNamespaces]);
45
 
46
+ const isNamespace = mode === 'namespace';
47
+ const title = isNamespace ? 'Run jobs as' : 'Jobs need Pro or a paid org';
48
+
49
+ const body = isNamespace
50
+ ? "Pick which paid organization should pay for and own this job. We'll use the same one for the rest of this browser."
51
+ : message;
52
+
53
  return (
54
  <Dialog
55
  open={open}
 
63
  border: '1px solid var(--border)',
64
  borderRadius: 'var(--radius-md)',
65
  boxShadow: 'var(--shadow-1)',
66
+ maxWidth: 460,
67
  mx: 2,
68
  },
69
  }}
 
71
  <DialogTitle
72
  sx={{ color: 'var(--text)', fontWeight: 700, fontSize: '1rem', pt: 2.5, pb: 0, px: 3 }}
73
  >
74
+ {title}
75
  </DialogTitle>
76
  <DialogContent sx={{ px: 3, pt: 1.25, pb: 0 }}>
77
  <DialogContentText
78
  sx={{ color: 'var(--muted-text)', fontSize: '0.85rem', lineHeight: 1.6 }}
79
  >
80
+ {body}
81
  </DialogContentText>
82
+
83
+ {isNamespace ? (
84
+ <FormControl fullWidth size="small" sx={{ mt: 2 }}>
85
+ <Select
86
+ value={selectedNamespace}
87
+ displayEmpty
88
+ onChange={(e) => setSelectedNamespace(String(e.target.value))}
 
 
 
 
 
89
  sx={{
90
+ bgcolor: 'var(--composer-bg)',
 
91
  color: 'var(--text)',
92
+ fontSize: '0.88rem',
93
+ fontWeight: 600,
94
+ '& .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--border)' },
95
+ '&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--border)' },
96
+ '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
97
+ borderColor: 'var(--accent-yellow)',
98
+ borderWidth: 1,
99
+ },
100
+ '& .MuiSelect-icon': { color: 'var(--muted-text)' },
101
+ }}
102
+ MenuProps={{
103
+ PaperProps: {
104
+ sx: {
105
+ bgcolor: 'var(--panel)',
106
+ border: '1px solid var(--border)',
107
+ borderRadius: '8px',
108
+ mt: 0.5,
109
+ },
110
+ },
111
  }}
112
  >
113
+ {eligibleNamespaces.map((namespace) => (
114
+ <MenuItem
115
+ key={namespace}
116
+ value={namespace}
117
+ sx={{
118
+ fontSize: '0.88rem',
119
+ color: 'var(--text)',
120
+ '&.Mui-selected': { bgcolor: 'rgba(255,255,255,0.05)' },
121
+ }}
 
122
  >
123
+ {namespace}
124
+ </MenuItem>
125
+ ))}
126
+ </Select>
127
+ </FormControl>
128
+ ) : (
129
+ eligibleNamespaces.length > 0 && (
130
+ <Box sx={{ mt: 1.5 }}>
131
  <Typography
132
  variant="caption"
133
+ sx={{ color: 'var(--muted-text)', fontSize: '0.78rem', lineHeight: 1.55 }}
134
  >
135
+ Eligible namespaces: {eligibleNamespaces.join(', ')}
136
  </Typography>
137
+ </Box>
138
+ )
139
  )}
 
 
 
 
 
 
140
  </DialogContent>
141
+ <DialogActions sx={{ px: 3, pb: 2.5, pt: 2.5, gap: 1 }}>
142
+ {isNamespace ? (
143
  <Button
144
  onClick={() => onContinueWithNamespace(selectedNamespace)}
145
  disabled={!selectedNamespace}
 
156
  '&:hover': { bgcolor: '#FFB340', boxShadow: 'none' },
157
  }}
158
  >
159
+ Continue
160
  </Button>
161
  ) : (
162
  <Button
 
192
  '&:hover': { bgcolor: 'var(--hover-bg)' },
193
  }}
194
  >
195
+ {isNamespace ? 'Skip this tool call' : 'Decline tool call'}
196
  </Button>
197
  </DialogActions>
198
  </Dialog>
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -447,6 +447,33 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
447
  }
448
  return;
449
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  logger.error('useChat error:', error);
451
  if (isActiveRef.current) {
452
  useAgentStore.getState().setError(error.message);
@@ -830,6 +857,9 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
830
  : approval.namespace,
831
  }));
832
 
 
 
 
833
  useAgentStore.getState().setJobsUpgradeRequired(null);
834
  return approveTools(approvals);
835
  }, [approveTools]);
 
447
  }
448
  return;
449
  }
450
+ if (error.message === 'HF_JOBS_INVALID_NAMESPACE') {
451
+ // Saved preference is no longer one of the user's eligible namespaces
452
+ // (e.g. they left the org). Clear it and reopen the picker.
453
+ const typed = error as Error & {
454
+ detail?: Record<string, unknown>;
455
+ approvals?: Array<{
456
+ tool_call_id: string;
457
+ approved: boolean;
458
+ feedback?: string | null;
459
+ edited_script?: string | null;
460
+ namespace?: string | null;
461
+ }>;
462
+ };
463
+ useAgentStore.getState().setPreferredJobsNamespace(null);
464
+ void hydrateFromBackend();
465
+ if (isActiveRef.current) {
466
+ useAgentStore.getState().setJobsUpgradeRequired({
467
+ approvals: typed.approvals || [],
468
+ toolCallIds: (typed.detail?.tool_call_ids as string[]) || [],
469
+ message: String(typed.detail?.message || 'Pick a different organization for this job run.'),
470
+ eligibleNamespaces: (typed.detail?.eligible_namespaces as string[]) || [],
471
+ plan: ((typed.detail?.plan as 'free' | 'pro' | 'org') || 'free'),
472
+ mode: 'namespace',
473
+ });
474
+ }
475
+ return;
476
+ }
477
  logger.error('useChat error:', error);
478
  if (isActiveRef.current) {
479
  useAgentStore.getState().setError(error.message);
 
857
  : approval.namespace,
858
  }));
859
 
860
+ // Remember this choice so the picker doesn't reappear for every
861
+ // subsequent hf_jobs call.
862
+ useAgentStore.getState().setPreferredJobsNamespace(namespace);
863
  useAgentStore.getState().setJobsUpgradeRequired(null);
864
  return approveTools(approvals);
865
  }, [approveTools]);
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -325,7 +325,14 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
325
  const approved = p.approval?.approved ?? true;
326
  // Get edited script from agentStore if available
327
  const editedScript = useAgentStore.getState().getEditedScript(p.toolCallId);
328
- const namespace = useAgentStore.getState().getApprovalNamespace(p.toolCallId);
 
 
 
 
 
 
 
329
  return {
330
  tool_call_id: p.toolCallId,
331
  approved,
@@ -393,6 +400,20 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
393
  throw err;
394
  }
395
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  if (!response.ok) {
397
  const errorText = await response.text().catch(() => 'Request failed');
398
  throw new Error(`Chat request failed: ${response.status} ${errorText}`);
 
325
  const approved = p.approval?.approved ?? true;
326
  // Get edited script from agentStore if available
327
  const editedScript = useAgentStore.getState().getEditedScript(p.toolCallId);
328
+ const explicitNamespace = useAgentStore.getState().getApprovalNamespace(p.toolCallId);
329
+ // Fall back to the user's persisted choice so we don't re-prompt
330
+ // every hf_jobs call. Backend will 400 if the saved namespace is
331
+ // no longer valid; the error handler clears the preference and
332
+ // reopens the picker.
333
+ const preferred = useAgentStore.getState().preferredJobsNamespace;
334
+ const namespace = explicitNamespace
335
+ ?? (approved && p.toolName === 'hf_jobs' ? preferred ?? null : null);
336
  return {
337
  tool_call_id: p.toolCallId,
338
  approved,
 
400
  throw err;
401
  }
402
  }
403
+ if (response.status === 400) {
404
+ const payload = await response.json().catch(() => null);
405
+ if (payload?.detail?.error === 'hf_jobs_invalid_namespace') {
406
+ // Stored namespace is no longer eligible β€” surface so the UI can
407
+ // clear the saved preference and reopen the picker.
408
+ const err = new Error('HF_JOBS_INVALID_NAMESPACE') as Error & {
409
+ detail?: Record<string, unknown>;
410
+ approvals?: Array<Record<string, unknown>>;
411
+ };
412
+ err.detail = payload.detail as Record<string, unknown>;
413
+ err.approvals = (body.approvals as Array<Record<string, unknown>> | undefined) || [];
414
+ throw err;
415
+ }
416
+ }
417
  if (!response.ok) {
418
  const errorText = await response.text().catch(() => 'Request failed');
419
  throw new Error(`Chat request failed: ${response.status} ${errorText}`);
frontend/src/store/agentStore.ts CHANGED
@@ -141,6 +141,10 @@ interface AgentStore {
141
  // Namespace overrides chosen for hf_jobs approvals (tool_call_id -> namespace)
142
  approvalNamespaces: Record<string, string>;
143
 
 
 
 
 
144
  // Job URLs (tool_call_id -> job URL) for HF jobs
145
  jobUrls: Record<string, string>;
146
 
@@ -199,6 +203,8 @@ interface AgentStore {
199
  getApprovalNamespace: (toolCallId: string) => string | undefined;
200
  clearApprovalNamespaces: () => void;
201
 
 
 
202
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
203
  getJobUrl: (toolCallId: string) => string | undefined;
204
 
@@ -292,6 +298,28 @@ function saveTrackioDashboards(dashboards: Record<string, { spaceId: string; pro
292
  }
293
  }
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  export const useAgentStore = create<AgentStore>()((set, get) => ({
296
  sessionStates: {},
297
  activeSessionId: null,
@@ -313,6 +341,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
313
 
314
  editedScripts: {},
315
  approvalNamespaces: {},
 
316
  jobUrls: {},
317
  jobStatuses: {},
318
  trackioDashboards: loadTrackioDashboards(),
@@ -494,6 +523,11 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
494
 
495
  clearApprovalNamespaces: () => set({ approvalNamespaces: {} }),
496
 
 
 
 
 
 
497
  // ── Job URLs ────────────────────────────────────────────────────────
498
 
499
  setJobUrl: (toolCallId, jobUrl) => {
 
141
  // Namespace overrides chosen for hf_jobs approvals (tool_call_id -> namespace)
142
  approvalNamespaces: Record<string, string>;
143
 
144
+ // Persisted preferred namespace for hf_jobs (auto-applied to future approvals
145
+ // so the user only picks once)
146
+ preferredJobsNamespace: string | null;
147
+
148
  // Job URLs (tool_call_id -> job URL) for HF jobs
149
  jobUrls: Record<string, string>;
150
 
 
203
  getApprovalNamespace: (toolCallId: string) => string | undefined;
204
  clearApprovalNamespaces: () => void;
205
 
206
+ setPreferredJobsNamespace: (namespace: string | null) => void;
207
+
208
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
209
  getJobUrl: (toolCallId: string) => string | undefined;
210
 
 
298
  }
299
  }
300
 
301
+ const PREFERRED_JOBS_NAMESPACE_KEY = 'hf-agent-preferred-jobs-namespace';
302
+
303
+ function loadPreferredJobsNamespace(): string | null {
304
+ try {
305
+ return localStorage.getItem(PREFERRED_JOBS_NAMESPACE_KEY);
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ function savePreferredJobsNamespace(namespace: string | null): void {
312
+ try {
313
+ if (namespace) {
314
+ localStorage.setItem(PREFERRED_JOBS_NAMESPACE_KEY, namespace);
315
+ } else {
316
+ localStorage.removeItem(PREFERRED_JOBS_NAMESPACE_KEY);
317
+ }
318
+ } catch (e) {
319
+ console.warn('Failed to persist preferred jobs namespace:', e);
320
+ }
321
+ }
322
+
323
  export const useAgentStore = create<AgentStore>()((set, get) => ({
324
  sessionStates: {},
325
  activeSessionId: null,
 
341
 
342
  editedScripts: {},
343
  approvalNamespaces: {},
344
+ preferredJobsNamespace: loadPreferredJobsNamespace(),
345
  jobUrls: {},
346
  jobStatuses: {},
347
  trackioDashboards: loadTrackioDashboards(),
 
523
 
524
  clearApprovalNamespaces: () => set({ approvalNamespaces: {} }),
525
 
526
+ setPreferredJobsNamespace: (namespace) => {
527
+ savePreferredJobsNamespace(namespace);
528
+ set({ preferredJobsNamespace: namespace });
529
+ },
530
+
531
  // ── Job URLs ────────────────────────────────────────────────────────
532
 
533
  setJobUrl: (toolCallId, jobUrl) => {