pepijn223 HF Staff commited on
Commit
30df68a
·
verified ·
1 Parent(s): 764e398

Simplify annotation setup and add compact expandable job rows

Browse files
assets/studio-cover.png CHANGED

Git LFS Details

  • SHA256: 584c53f4efdc778bf6f9b530732ec2ba31f9bf1d9f19763180f656bc3600b3bb
  • Pointer size: 131 Bytes
  • Size of remote file: 348 kB

Git LFS Details

  • SHA256: e349ac1753bad00c9bc504cfe2bfd0a7e7a8c626279e5e0c133d80027b694a5b
  • Pointer size: 131 Bytes
  • Size of remote file: 346 kB
assets/studio-cover.svg CHANGED
deploy.py CHANGED
@@ -57,7 +57,7 @@ def main():
57
  "frontend/dist/**",
58
  ],
59
  delete_patterns=["app.py"],
60
- commit_message="Brand Annotation Studio and copy only successfully annotated episodes",
61
  )
62
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
63
 
 
57
  "frontend/dist/**",
58
  ],
59
  delete_patterns=["app.py"],
60
+ commit_message="Simplify annotation setup and add compact expandable job rows",
61
  )
62
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
63
 
frontend/src/JobsView.tsx ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ ArrowUpRight,
4
+ ChevronDown,
5
+ LoaderCircle,
6
+ Plus,
7
+ RefreshCw,
8
+ } from "lucide-react";
9
+ import "./jobs.css";
10
+
11
+ export type Job = {
12
+ id: string;
13
+ name?: string;
14
+ stage: string;
15
+ created_at: string;
16
+ owner: string;
17
+ output?: string;
18
+ };
19
+ export type Progress = {
20
+ model?: string;
21
+ status: string;
22
+ expected: number;
23
+ completed: number;
24
+ failed: number;
25
+ stopped?: number;
26
+ pending?: number;
27
+ usage: {
28
+ measured_inference_usd?: number;
29
+ successful_calls?: number;
30
+ input_tokens?: number;
31
+ output_tokens?: number;
32
+ rate_limited_requests?: number;
33
+ };
34
+ cost?: {
35
+ inference_usd: number | null;
36
+ cpu_usd: number | null;
37
+ total_usd: number | null;
38
+ uncertain_inference_usd: number;
39
+ running_seconds: number | null;
40
+ basis: string;
41
+ billing_url: string;
42
+ };
43
+ annotation_url?: string | null;
44
+ output_mode?: string;
45
+ };
46
+ const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
47
+ const usd = (value: number | null | undefined) =>
48
+ value == null
49
+ ? "—"
50
+ : new Intl.NumberFormat("en-US", {
51
+ style: "currency",
52
+ currency: "USD",
53
+ minimumFractionDigits: value > 0 && value < 1 ? 4 : 2,
54
+ maximumFractionDigits: 4,
55
+ }).format(value);
56
+
57
+ function status(job: Job, progress?: Progress) {
58
+ if (job.stage === "ERROR") return "Failed";
59
+ if (job.stage === "COMPLETED")
60
+ return progress?.status === "partial" ? "Partial" : "Completed";
61
+ if (job.stage === "CANCELED") return "Canceled";
62
+ if (["PENDING", "QUEUED"].includes(job.stage)) return "Queued";
63
+ return job.stage[0] + job.stage.slice(1).toLowerCase();
64
+ }
65
+
66
+ export default function JobsView({
67
+ jobs,
68
+ progress,
69
+ models,
70
+ signedIn,
71
+ loading,
72
+ signIn,
73
+ onRefresh,
74
+ onNew,
75
+ onCancel,
76
+ }: {
77
+ jobs: Job[];
78
+ progress: Record<string, Progress>;
79
+ models: { route: string; name: string; provider_name: string }[];
80
+ signedIn: boolean;
81
+ loading: boolean;
82
+ signIn: ReactNode;
83
+ onRefresh: () => void;
84
+ onNew: () => void;
85
+ onCancel: (id: string) => void;
86
+ }) {
87
+ return (
88
+ <div className="jobs-page">
89
+ <div className="page-heading">
90
+ <h1>Jobs</h1>
91
+ <div className="heading-actions">
92
+ <button
93
+ className="icon-button"
94
+ onClick={onRefresh}
95
+ disabled={!signedIn || loading}
96
+ aria-label="Refresh jobs"
97
+ >
98
+ <RefreshCw size={17} className={loading ? "spin" : ""} />
99
+ </button>
100
+ <button className="button primary" onClick={onNew}>
101
+ <Plus size={15} /> New job
102
+ </button>
103
+ </div>
104
+ </div>
105
+ {!signedIn ? (
106
+ <div className="jobs-empty">
107
+ <p>Sign in to see your jobs.</p>
108
+ {signIn}
109
+ </div>
110
+ ) : jobs.length === 0 ? (
111
+ <div className="jobs-empty">
112
+ {loading ? (
113
+ <>
114
+ <LoaderCircle size={20} className="spin" />
115
+ <p>Loading jobs…</p>
116
+ </>
117
+ ) : (
118
+ <>
119
+ <p>No jobs yet.</p>
120
+ <button className="button secondary" onClick={onNew}>
121
+ New annotation
122
+ </button>
123
+ </>
124
+ )}
125
+ </div>
126
+ ) : (
127
+ <div className="compact-jobs">
128
+ <div className="jobs-columns" aria-hidden="true">
129
+ <span>Job</span>
130
+ <span>Status</span>
131
+ <span>Episodes</span>
132
+ <span>Usage cost</span>
133
+ <span />
134
+ </div>
135
+ {jobs.map((job) => {
136
+ const p = progress[job.id];
137
+ const ended = terminal.has(job.stage);
138
+ const state = status(job, p);
139
+ const profile = models.find((m) => m.route === p?.model);
140
+ const model =
141
+ profile?.name || p?.model?.split(":")[0].split("/").pop();
142
+ const provider = profile?.provider_name || p?.model?.split(":")[1];
143
+ const name =
144
+ job.output?.split("/")[1] || job.name || "Annotation job";
145
+ const logs = `https://huggingface.co/jobs/${job.owner}/${job.id}`;
146
+ const results =
147
+ job.output && p?.completed
148
+ ? `https://huggingface.co/datasets/${job.output}`
149
+ : null;
150
+ const annotations = ended ? p?.annotation_url : null;
151
+ const action = annotations
152
+ ? "Show annotations"
153
+ : results
154
+ ? "Results"
155
+ : "Logs";
156
+ const knownCost = p?.cost?.total_usd ?? p?.cost?.cpu_usd;
157
+ const incompleteCost =
158
+ p?.cost &&
159
+ (p.cost.total_usd == null ||
160
+ p.cost.uncertain_inference_usd > 0.000001);
161
+ return (
162
+ <details className="compact-job" key={job.id}>
163
+ <summary className="compact-job-row">
164
+ <span className="job-identity">
165
+ <strong title={job.output || name}>{name}</strong>
166
+ <span>
167
+ <time dateTime={job.created_at}>
168
+ {new Date(job.created_at).toLocaleString(undefined, {
169
+ month: "short",
170
+ day: "numeric",
171
+ hour: "2-digit",
172
+ minute: "2-digit",
173
+ })}
174
+ </time>
175
+ {model && (
176
+ <>
177
+ <span className="job-meta-separator">·</span>
178
+ <span className="job-model">{model}</span>
179
+ </>
180
+ )}
181
+ </span>
182
+ </span>
183
+ <span className={`compact-status ${state.toLowerCase()}`}>
184
+ <i />
185
+ {state}
186
+ </span>
187
+ <span className="compact-progress">
188
+ <span>
189
+ {p?.expected
190
+ ? `${p.completed.toLocaleString()} / ${p.expected.toLocaleString()}`
191
+ : "—"}
192
+ <span className="mobile-label"> episodes</span>
193
+ </span>
194
+ {!ended && !!p?.expected && (
195
+ <progress
196
+ max={p.expected}
197
+ value={p.completed}
198
+ aria-label={`${name} episodes completed`}
199
+ />
200
+ )}
201
+ </span>
202
+ <span
203
+ className="compact-cost"
204
+ title={
205
+ incompleteCost
206
+ ? "Some usage is unconfirmed. Expand for details."
207
+ : "Usage-derived cost"
208
+ }
209
+ >
210
+ <span className="mobile-label">Cost </span>
211
+ {usd(knownCost)}
212
+ {incompleteCost && knownCost != null && <sup>*</sup>}
213
+ </span>
214
+ <span className="compact-job-action">
215
+ <a
216
+ href={annotations || results || logs}
217
+ target="_blank"
218
+ rel="noreferrer"
219
+ onClick={(e) => e.stopPropagation()}
220
+ >
221
+ {action}
222
+ <ArrowUpRight size={13} />
223
+ </a>
224
+ <ChevronDown
225
+ size={15}
226
+ className="job-expand"
227
+ aria-hidden="true"
228
+ />
229
+ </span>
230
+ </summary>
231
+ <div className="compact-job-details">
232
+ <dl>
233
+ <div>
234
+ <dt>Model</dt>
235
+ <dd>
236
+ {model
237
+ ? `${model}${provider ? ` · ${provider}` : ""}`
238
+ : "—"}
239
+ </dd>
240
+ </div>
241
+ <div>
242
+ <dt>Inference</dt>
243
+ <dd>{usd(p?.cost?.inference_usd)}</dd>
244
+ </div>
245
+ <div>
246
+ <dt>Compute</dt>
247
+ <dd>{usd(p?.cost?.cpu_usd)}</dd>
248
+ </div>
249
+ <div>
250
+ <dt>Model calls</dt>
251
+ <dd>
252
+ {p?.usage.successful_calls?.toLocaleString() ?? "—"}
253
+ </dd>
254
+ </div>
255
+ </dl>
256
+ {p &&
257
+ (p.failed > 0 ||
258
+ (p.stopped || 0) > 0 ||
259
+ (p.pending || 0) > 0) && (
260
+ <p className="job-outcomes">
261
+ {[
262
+ p.failed ? `${p.failed} failed` : "",
263
+ p.stopped ? `${p.stopped} stopped` : "",
264
+ p.pending ? `${p.pending} not processed` : "",
265
+ ]
266
+ .filter(Boolean)
267
+ .join(" · ")}
268
+ </p>
269
+ )}
270
+ {incompleteCost && (
271
+ <p className="job-cost-note">
272
+ {p.cost!.uncertain_inference_usd > 0.000001
273
+ ? `Unconfirmed inference: up to ${usd(p.cost!.uncertain_inference_usd)} extra.`
274
+ : "Some usage has not been reported yet."}
275
+ </p>
276
+ )}
277
+ <div className="job-detail-actions">
278
+ <a href={logs} target="_blank" rel="noreferrer">
279
+ Logs <ArrowUpRight size={12} />
280
+ </a>
281
+ {results && (
282
+ <a href={results} target="_blank" rel="noreferrer">
283
+ Output files <ArrowUpRight size={12} />
284
+ </a>
285
+ )}
286
+ {p?.cost && (
287
+ <a
288
+ href={p.cost.billing_url}
289
+ target="_blank"
290
+ rel="noreferrer"
291
+ >
292
+ Billing <ArrowUpRight size={12} />
293
+ </a>
294
+ )}
295
+ {!ended && (
296
+ <button
297
+ className="text-danger"
298
+ onClick={() => onCancel(job.id)}
299
+ >
300
+ Cancel job
301
+ </button>
302
+ )}
303
+ </div>
304
+ <p className="job-billing-note">
305
+ Usage estimates; final billing may differ.
306
+ </p>
307
+ </div>
308
+ </details>
309
+ );
310
+ })}
311
+ </div>
312
+ )}
313
+ </div>
314
+ );
315
+ }
frontend/src/jobs.css ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .jobs-page {
2
+ max-width: 1100px;
3
+ margin: 0 auto 56px;
4
+ }
5
+ .jobs-page h1 {
6
+ font-size: 27px;
7
+ font-weight: 600;
8
+ }
9
+ .jobs-page .page-heading {
10
+ margin-bottom: 30px;
11
+ }
12
+ .jobs-empty {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ gap: 20px;
17
+ padding: 100px 20px;
18
+ color: var(--fg-muted);
19
+ font-size: 14px;
20
+ }
21
+ .jobs-columns,
22
+ .compact-job-row {
23
+ display: grid;
24
+ grid-template-columns: minmax(180px, 1fr) 92px 85px 90px 155px;
25
+ gap: 20px;
26
+ align-items: center;
27
+ }
28
+ .jobs-columns {
29
+ padding: 0 21px 12px;
30
+ color: var(--fg-muted);
31
+ font-size: 11px;
32
+ }
33
+ .compact-jobs {
34
+ min-width: 0;
35
+ }
36
+ .compact-job {
37
+ background: var(--surface-0);
38
+ border: 1px solid var(--line);
39
+ border-bottom: 0;
40
+ }
41
+ .compact-job:nth-child(2) {
42
+ border-radius: 10px 10px 0 0;
43
+ }
44
+ .compact-job:last-child {
45
+ border-bottom: 1px solid var(--line);
46
+ border-radius: 0 0 10px 10px;
47
+ }
48
+ .compact-job:nth-child(2):last-child {
49
+ border-radius: 10px;
50
+ }
51
+ .compact-job-row {
52
+ list-style: none;
53
+ cursor: pointer;
54
+ padding: 21px;
55
+ font-size: 12px;
56
+ }
57
+ .compact-job-row::-webkit-details-marker {
58
+ display: none;
59
+ }
60
+ .compact-job-row:hover {
61
+ background: var(--surface-1);
62
+ }
63
+ .compact-job-row:focus-visible {
64
+ outline: 2px solid #66aafc;
65
+ outline-offset: -2px;
66
+ border-radius: 7px;
67
+ }
68
+ .job-identity {
69
+ min-width: 0;
70
+ }
71
+ .job-identity > strong {
72
+ display: block;
73
+ font-size: 13px;
74
+ font-weight: 550;
75
+ overflow: hidden;
76
+ white-space: nowrap;
77
+ text-overflow: ellipsis;
78
+ line-height: 1.6;
79
+ }
80
+ .job-identity > span {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 7px;
84
+ min-width: 0;
85
+ color: var(--fg-muted);
86
+ font-size: 11px;
87
+ margin-top: 5px;
88
+ }
89
+ .job-identity time {
90
+ flex-shrink: 0;
91
+ }
92
+ .job-model {
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ white-space: nowrap;
96
+ }
97
+ .compact-status {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ gap: 6px;
101
+ font-size: 11px;
102
+ color: var(--fg-muted);
103
+ }
104
+ .compact-status i {
105
+ width: 5px;
106
+ height: 5px;
107
+ border-radius: 50%;
108
+ background: currentColor;
109
+ }
110
+ .compact-status.completed {
111
+ color: var(--green);
112
+ }
113
+ .compact-status.running {
114
+ color: #5176cb;
115
+ }
116
+ .compact-status.partial {
117
+ color: #a97c22;
118
+ }
119
+ .compact-status.failed {
120
+ color: #c35757;
121
+ }
122
+ .dark .compact-status.failed {
123
+ color: #ef9797;
124
+ }
125
+ .dark .compact-status.running {
126
+ color: #99b7f5;
127
+ }
128
+ .dark .compact-status.partial {
129
+ color: #e7bf70;
130
+ }
131
+ .compact-progress {
132
+ font-variant-numeric: tabular-nums;
133
+ line-height: 1.5;
134
+ }
135
+ .compact-progress progress {
136
+ display: block;
137
+ width: 100%;
138
+ height: 3px;
139
+ margin-top: 7px;
140
+ border: 0;
141
+ border-radius: 5px;
142
+ overflow: hidden;
143
+ background: var(--surface-2);
144
+ accent-color: var(--fg-muted);
145
+ }
146
+ .compact-progress progress::-webkit-progress-bar {
147
+ background: var(--surface-2);
148
+ }
149
+ .compact-progress progress::-webkit-progress-value {
150
+ background: var(--fg-muted);
151
+ }
152
+ .compact-cost {
153
+ font-variant-numeric: tabular-nums;
154
+ }
155
+ .compact-cost sup {
156
+ color: var(--fg-muted);
157
+ padding-left: 2px;
158
+ }
159
+ .compact-job-action {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: flex-end;
163
+ gap: 18px;
164
+ }
165
+ .compact-job-action a {
166
+ display: inline-flex;
167
+ align-items: center;
168
+ gap: 5px;
169
+ font-size: 12px;
170
+ font-weight: 500;
171
+ white-space: nowrap;
172
+ padding: 8px 0;
173
+ }
174
+ .compact-job-action a:hover,
175
+ .job-detail-actions a:hover {
176
+ text-decoration: underline;
177
+ text-underline-offset: 3px;
178
+ }
179
+ .job-expand {
180
+ color: var(--fg-muted);
181
+ }
182
+ .compact-job[open] .job-expand {
183
+ transform: rotate(180deg);
184
+ }
185
+ .compact-job-details {
186
+ padding: 0 21px 21px;
187
+ }
188
+ .compact-job-details dl {
189
+ margin: 0;
190
+ padding-top: 20px;
191
+ border-top: 1px solid var(--line);
192
+ display: grid;
193
+ grid-template-columns: 2fr 1fr 1fr 1fr;
194
+ gap: 20px;
195
+ }
196
+ .compact-job-details dt {
197
+ font-size: 11px;
198
+ color: var(--fg-muted);
199
+ margin-bottom: 6px;
200
+ }
201
+ .compact-job-details dd {
202
+ font-size: 12px;
203
+ margin: 0;
204
+ line-height: 1.6;
205
+ overflow-wrap: anywhere;
206
+ }
207
+ .job-outcomes,
208
+ .job-cost-note {
209
+ color: var(--fg-muted);
210
+ font-size: 12px;
211
+ margin-top: 17px;
212
+ }
213
+ .job-cost-note {
214
+ color: var(--fg-soft);
215
+ }
216
+ .job-detail-actions {
217
+ display: flex;
218
+ align-items: center;
219
+ flex-wrap: wrap;
220
+ gap: 20px;
221
+ margin-top: 22px;
222
+ }
223
+ .job-detail-actions a,
224
+ .job-detail-actions button {
225
+ display: inline-flex;
226
+ align-items: center;
227
+ gap: 4px;
228
+ font-size: 12px;
229
+ padding: 0;
230
+ }
231
+ .job-detail-actions .text-danger {
232
+ margin-left: auto;
233
+ }
234
+ .job-billing-note {
235
+ font-size: 11px;
236
+ color: var(--fg-faint);
237
+ margin-top: 14px;
238
+ }
239
+ .mobile-label {
240
+ display: none;
241
+ }
242
+ @media (max-width: 1200px) {
243
+ .jobs-columns,
244
+ .compact-job-row {
245
+ grid-template-columns: minmax(160px, 1fr) 80px 65px 75px 142px;
246
+ gap: 12px;
247
+ }
248
+ }
249
+ @media (max-width: 800px) {
250
+ .jobs-columns {
251
+ display: none;
252
+ }
253
+ .compact-job-row {
254
+ grid-template-columns: minmax(0, 1fr) auto;
255
+ gap: 15px 12px;
256
+ }
257
+ .job-identity {
258
+ grid-column: 1;
259
+ grid-row: 1;
260
+ }
261
+ .compact-status {
262
+ grid-column: 2;
263
+ grid-row: 1;
264
+ justify-self: end;
265
+ }
266
+ .compact-progress {
267
+ grid-column: 1;
268
+ grid-row: 2;
269
+ font-size: 11px;
270
+ color: var(--fg-muted);
271
+ }
272
+ .compact-cost {
273
+ grid-column: 1;
274
+ grid-row: 3;
275
+ font-size: 11px;
276
+ }
277
+ .compact-job-action {
278
+ grid-column: 2;
279
+ grid-row: 2 / 4;
280
+ align-self: end;
281
+ }
282
+ .compact-progress progress {
283
+ width: 110px;
284
+ }
285
+ .mobile-label {
286
+ display: inline;
287
+ }
288
+ .compact-job-details dl {
289
+ grid-template-columns: 1fr 1fr;
290
+ }
291
+ .compact-job-row,
292
+ .compact-job-details {
293
+ padding-left: 17px;
294
+ padding-right: 17px;
295
+ }
296
+ .job-identity > span {
297
+ flex-wrap: wrap;
298
+ gap: 2px 7px;
299
+ }
300
+ .job-model {
301
+ flex-basis: 100%;
302
+ }
303
+ .job-meta-separator {
304
+ display: none;
305
+ }
306
+ .compact-job-action {
307
+ gap: 12px;
308
+ }
309
+ }
310
+
311
+ .jobs-page .page-heading .heading-actions {
312
+ flex-direction: row;
313
+ align-items: center;
314
+ }
frontend/src/main.tsx CHANGED
@@ -1,7 +1,6 @@
1
  import { useEffect, useRef, useState } from "react";
2
  import { createRoot } from "react-dom/client";
3
  import {
4
- ArrowDownToLine,
5
  ArrowLeft,
6
  ArrowRight,
7
  ArrowUpRight,
@@ -12,13 +11,10 @@ import {
12
  Check,
13
  CheckCheck,
14
  ChevronDown,
15
- ChevronRight,
16
  Coins,
17
  Database,
18
  FileJson2,
19
  FolderOpen,
20
- Gauge,
21
- Github,
22
  Globe,
23
  HelpCircle,
24
  Layers3,
@@ -34,9 +30,7 @@ import {
34
  Rocket,
35
  Search,
36
  Settings2,
37
- ShieldCheck,
38
  Sun,
39
- Terminal,
40
  Video,
41
  WandSparkles,
42
  X,
@@ -44,6 +38,7 @@ import {
44
  import "./style.css";
45
  import "./wizard.css";
46
  import AnnotationExamples from "./AnnotationExamples";
 
47
 
48
  type Feature =
49
  | "Subtasks"
@@ -127,41 +122,6 @@ type Session = {
127
  csrf: string;
128
  auth_available: boolean;
129
  };
130
- type Job = {
131
- id: string;
132
- name?: string;
133
- stage: string;
134
- created_at: string;
135
- owner: string;
136
- output?: string;
137
- };
138
- type Progress = {
139
- model?: string;
140
- status: string;
141
- expected: number;
142
- completed: number;
143
- failed: number;
144
- stopped?: number;
145
- pending?: number;
146
- usage: {
147
- measured_inference_usd?: number;
148
- successful_calls?: number;
149
- input_tokens?: number;
150
- output_tokens?: number;
151
- rate_limited_requests?: number;
152
- };
153
- cost?: {
154
- inference_usd: number | null;
155
- cpu_usd: number | null;
156
- total_usd: number | null;
157
- uncertain_inference_usd: number;
158
- running_seconds: number | null;
159
- basis: string;
160
- billing_url: string;
161
- };
162
- annotation_url?: string | null;
163
- output_mode?: string;
164
- };
165
  const EXPORT = "Annotation files only",
166
  COPY = "Annotated dataset copy";
167
  const defaults: Options = {
@@ -242,7 +202,6 @@ const money = (n: number) =>
242
  const num = (n: number) => new Intl.NumberFormat("en-US").format(n);
243
  const duration = (s: number) =>
244
  s >= 3600 ? `${(s / 3600).toFixed(1)} hours` : `${(s / 60).toFixed(1)} min`;
245
- const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
246
  function initial() {
247
  try {
248
  return {
@@ -741,16 +700,6 @@ function App() {
741
  <BookOpen size={18} /> How it works
742
  </button>
743
  </nav>
744
- <div className="sidebar-bottom">
745
- <a
746
- className="source-link"
747
- href="https://huggingface.co/spaces/lerobot/annotation-studio/tree/main"
748
- target="_blank"
749
- rel="noreferrer"
750
- >
751
- <Github size={16} /> Open source <ArrowUpRight size={13} />
752
- </a>
753
- </div>
754
  </aside>
755
  <div className="workspace">
756
  <header className="topbar">
@@ -762,8 +711,6 @@ function App() {
762
  >
763
  <Menu size={20} />
764
  </button>
765
- <span>Workspace</span>
766
- <ChevronRight size={14} />
767
  <strong>
768
  {page === "new"
769
  ? "New annotation"
@@ -826,7 +773,6 @@ function App() {
826
  <div className="page-heading">
827
  <div>
828
  <h1>New annotation</h1>
829
- <p>A little more context for your robot data.</p>
830
  </div>
831
  </div>
832
  <nav className="wizard-steps" aria-label="Annotation setup">
@@ -858,7 +804,6 @@ function App() {
858
  ))}
859
  </nav>
860
  <div className="step-intro">
861
- <span>STEP {step + 1} OF 4</span>
862
  <h2 ref={stepHeading} tabIndex={-1}>
863
  {
864
  [
@@ -869,16 +814,6 @@ function App() {
869
  ][step]
870
  }
871
  </h2>
872
- <p>
873
- {
874
- [
875
- "Select a dataset, episode range and camera view.",
876
- "Start with subtasks, or add more context to each episode.",
877
- "Use the default, or choose another vision model.",
878
- "Check the estimate and choose where to save your annotations.",
879
- ][step]
880
- }
881
- </p>
882
  </div>
883
  {step === 0 && (
884
  <section
@@ -886,7 +821,7 @@ function App() {
886
  aria-label="Dataset settings"
887
  >
888
  <label className="field-label" htmlFor="dataset">
889
- Dataset ID <span>Public or private</span>
890
  </label>
891
  <div className="dataset-input">
892
  <Database size={17} />
@@ -901,8 +836,7 @@ function App() {
901
  {metadata && <Check size={18} className="success-text" />}
902
  </div>
903
  <div className="field-help">
904
- Use <code>owner/dataset</code>. Sign in to access private or
905
- gated datasets.
906
  </div>
907
  <div className="fields-row">
908
  <label>
@@ -1112,9 +1046,6 @@ function App() {
1112
 
1113
  {selectedModel && (
1114
  <>
1115
- <p className="model-description">
1116
- {selectedModel.description}
1117
- </p>
1118
  <div className="model-overview">
1119
  <span>{selectedModel.provider_name}</span>
1120
  <span>
@@ -1271,14 +1202,6 @@ function App() {
1271
  </label>
1272
  ))}
1273
  </div>
1274
- {options.mode === COPY && (
1275
- <p className="field-help">
1276
- Only successfully annotated episodes are copied. Videos
1277
- from all RGB cameras are trimmed to those episodes;
1278
- skipped and failed episodes are excluded. Storage
1279
- charges are separate.
1280
- </p>
1281
- )}
1282
  <fieldset className="visibility-control">
1283
  <legend>Repository visibility</legend>
1284
  <div className="visibility-options">
@@ -1310,15 +1233,6 @@ function App() {
1310
  </label>
1311
  ))}
1312
  </div>
1313
- {visibility === "public" && (
1314
- <p className="field-help">
1315
- {options.mode === COPY
1316
- ? "Copied episodes, including their videos and annotations, will be public."
1317
- : "Annotations, source references and run details will be public. Source videos are not copied."}
1318
- {quote?.private &&
1319
- " Your source dataset is private; this publishes content derived from it."}
1320
- </p>
1321
- )}
1322
  </fieldset>
1323
  <button
1324
  className="disclosure"
@@ -1361,14 +1275,6 @@ function App() {
1361
  </select>
1362
  </label>
1363
  </div>
1364
- <p>
1365
- The tuned preset is experimental, not finetuned
1366
- weights. Its small held-out benchmark did not
1367
- establish a quality improvement. Image and JSON
1368
- compatibility checks do not establish annotation
1369
- quality. Provider limits and smaller budgets can
1370
- reduce parallelism.
1371
- </p>
1372
  </div>
1373
  )}
1374
  </section>
@@ -1387,9 +1293,6 @@ function App() {
1387
  <span>–</span>
1388
  {money(quote.costs.high)}
1389
  </div>
1390
- <p className="price-description">
1391
- Based on your selected video and annotations.
1392
- </p>
1393
  <details className="progressive-details cost-breakdown">
1394
  <summary>
1395
  Cost breakdown <ChevronDown size={15} />
@@ -1487,8 +1390,7 @@ function App() {
1487
  />
1488
  </div>
1489
  <p className="microcopy">
1490
- The worker checks cost before each request. Completed
1491
- results are kept if the target or timeout is reached.
1492
  </p>
1493
  {quote && budget < quote.costs.high && (
1494
  <div className="budget-hint">
@@ -1525,27 +1427,11 @@ function App() {
1525
  <Rocket size={16} /> Review & launch{" "}
1526
  <ArrowRight size={16} />
1527
  </button>
1528
- <div className="billed-to">
1529
- Billed to <strong>{session.user.name}</strong>
1530
- </div>
1531
  </>
1532
  ) : (
1533
- <>
1534
- {signIn}
1535
- <div className="billed-to">
1536
- To launch jobs in your own account.
1537
- </div>
1538
- </>
1539
  )}
1540
  </div>
1541
- <div className="summary-fineprint">
1542
- <ShieldCheck size={14} />
1543
- <p>
1544
- Your dataset is preserved. Run costs are billed to you
1545
- through Hugging Face. This spending guard is not a
1546
- billing guarantee.
1547
- </p>
1548
- </div>
1549
  </section>
1550
  </div>
1551
  )}
@@ -1559,9 +1445,7 @@ function App() {
1559
  <ArrowLeft size={15} /> Back
1560
  </button>
1561
  ) : (
1562
- <span className="quiet-note">
1563
- Your source dataset stays unchanged.
1564
- </span>
1565
  )}
1566
  {step < 3 && (
1567
  <button
@@ -1593,271 +1477,17 @@ function App() {
1593
  </div>
1594
  )}
1595
  {page === "jobs" && (
1596
- <>
1597
- <div className="page-heading">
1598
- <div>
1599
- <div className="overline">YOUR WORKSPACE</div>
1600
- <h1>Annotation jobs</h1>
1601
- <p>
1602
- Follow runs, inspect outputs, and pick up where your data
1603
- left off.
1604
- </p>
1605
- </div>
1606
- <div className="heading-actions">
1607
- <button
1608
- className="button secondary"
1609
- onClick={loadJobs}
1610
- disabled={!session?.user || jobsLoading}
1611
- >
1612
- <RefreshCw
1613
- size={15}
1614
- className={jobsLoading ? "spin" : ""}
1615
- />{" "}
1616
- Refresh
1617
- </button>
1618
- <button
1619
- className="button primary"
1620
- onClick={() => navigate("new")}
1621
- >
1622
- <Plus size={16} /> New job
1623
- </button>
1624
- </div>
1625
- </div>
1626
- {!session?.user ? (
1627
- <div className="empty-state card">
1628
- <div className="empty-icon">
1629
- <FolderOpen size={30} />
1630
- </div>
1631
- <h2>Your runs, in one place.</h2>
1632
- <p>
1633
- Sign in to see the annotation jobs launched from your
1634
- account.
1635
- </p>
1636
- {signIn}
1637
- </div>
1638
- ) : jobs.length === 0 ? (
1639
- <div className="empty-state card">
1640
- <div className="empty-icon">
1641
- <Rocket size={30} />
1642
- </div>
1643
- <h2>
1644
- {jobsLoading
1645
- ? "Loading your jobs…"
1646
- : "Your first annotation starts here."}
1647
- </h2>
1648
- <p>
1649
- Configure a dataset, review the estimate, and launch your
1650
- first job.
1651
- </p>
1652
- <button
1653
- className="button primary"
1654
- onClick={() => navigate("new")}
1655
- >
1656
- <Plus size={16} /> Create an annotation job
1657
- </button>
1658
- </div>
1659
- ) : (
1660
- <div className="jobs-list">
1661
- {jobs.map((j) => (
1662
- <article className="card job" key={j.id}>
1663
- <div className="job-head">
1664
- <span className="section-icon">
1665
- <Layers3 size={19} />
1666
- </span>
1667
- <div>
1668
- <h2>
1669
- {j.output?.split("/")[1] ||
1670
- j.name ||
1671
- "Annotation job"}
1672
- </h2>
1673
- <p>
1674
- {new Date(j.created_at).toLocaleString()} ·{" "}
1675
- <code>{j.id.slice(0, 8)}</code>
1676
- </p>
1677
- {progress[j.id]?.model && (
1678
- <p>
1679
- {modelCatalog?.models.find(
1680
- (m) => m.route === progress[j.id].model,
1681
- )?.name ||
1682
- progress[j.id].model
1683
- ?.split(":")[0]
1684
- .split("/")
1685
- .pop()}{" "}
1686
- ·{" "}
1687
- {modelCatalog?.models.find(
1688
- (m) => m.route === progress[j.id].model,
1689
- )?.provider_name ||
1690
- progress[j.id].model?.split(":")[1]}
1691
- </p>
1692
- )}
1693
- </div>
1694
- <span
1695
- className={`job-status ${progress[j.id]?.status === "partial" ? "partial" : j.stage.toLowerCase()}`}
1696
- >
1697
- {!terminal.has(j.stage) && (
1698
- <LoaderCircle size={12} className="spin" />
1699
- )}
1700
- {progress[j.id]?.status === "partial"
1701
- ? "Partial"
1702
- : j.stage.toLowerCase()}
1703
- </span>
1704
- </div>
1705
- {progress[j.id] && (
1706
- <div className="job-progress">
1707
- <div>
1708
- <span>
1709
- {num(progress[j.id].completed)} /{" "}
1710
- {num(progress[j.id].expected)} episodes saved
1711
- </span>
1712
- <strong>{progress[j.id].status}</strong>
1713
- </div>
1714
- <progress
1715
- max={progress[j.id].expected}
1716
- value={progress[j.id].completed}
1717
- />
1718
- <small>
1719
- {progress[j.id].failed} failed ·{" "}
1720
- {progress[j.id].stopped || 0} stopped ·{" "}
1721
- {progress[j.id].pending || 0} not processed ·
1722
- measured inference{" "}
1723
- {money(
1724
- progress[j.id].usage.measured_inference_usd || 0,
1725
- )}
1726
- </small>
1727
- </div>
1728
- )}
1729
- {progress[j.id]?.cost && (
1730
- <div className="job-cost">
1731
- <div className="job-cost-total">
1732
- <div>
1733
- <span>Usage cost</span>
1734
- <strong>
1735
- {progress[j.id].cost!.total_usd == null
1736
- ? "Pending"
1737
- : new Intl.NumberFormat("en-US", {
1738
- style: "currency",
1739
- currency: "USD",
1740
- minimumFractionDigits: 4,
1741
- }).format(progress[j.id].cost!.total_usd!)}
1742
- </strong>
1743
- </div>
1744
- <span className="quiet-badge">
1745
- {terminal.has(j.stage)
1746
- ? "Run ended"
1747
- : "Updates every 15 s"}
1748
- </span>
1749
- </div>
1750
- <div className="job-cost-breakdown">
1751
- <span>
1752
- Model inference{" "}
1753
- <b>
1754
- {progress[j.id].cost!.inference_usd == null
1755
- ? "Pending"
1756
- : "$" +
1757
- progress[j.id].cost!.inference_usd!.toFixed(
1758
- 4,
1759
- )}
1760
- </b>
1761
- </span>
1762
- <span>
1763
- CPU runtime{" "}
1764
- <b>
1765
- {progress[j.id].cost!.cpu_usd == null
1766
- ? "Pending"
1767
- : "$" +
1768
- progress[j.id].cost!.cpu_usd!.toFixed(4)}
1769
- </b>
1770
- </span>
1771
- <span>
1772
- Model calls{" "}
1773
- <b>
1774
- {num(
1775
- progress[j.id].usage.successful_calls || 0,
1776
- )}
1777
- </b>
1778
- </span>
1779
- </div>
1780
- {progress[j.id].cost!.uncertain_inference_usd >
1781
- 0.000001 && (
1782
- <p className="cost-uncertain">
1783
- Additional uncertain inference charges: up to $
1784
- {progress[
1785
- j.id
1786
- ].cost!.uncertain_inference_usd.toFixed(4)}
1787
- . These are reserved separately.
1788
- </p>
1789
- )}
1790
- <p>
1791
- {progress[j.id].cost!.basis}{" "}
1792
- <a
1793
- href={progress[j.id].cost!.billing_url}
1794
- target="_blank"
1795
- rel="noreferrer"
1796
- >
1797
- View HF billing <ArrowUpRight size={10} />
1798
- </a>
1799
- </p>
1800
- </div>
1801
- )}
1802
- <div className="job-actions">
1803
- {terminal.has(j.stage) &&
1804
- progress[j.id]?.annotation_url && (
1805
- <a
1806
- className="button primary"
1807
- href={progress[j.id].annotation_url!}
1808
- target="_blank"
1809
- rel="noreferrer"
1810
- >
1811
- <Layers3 size={14} /> Show annotations{" "}
1812
- <ArrowUpRight size={13} />
1813
- </a>
1814
- )}
1815
- <button
1816
- className="button secondary"
1817
- onClick={() => getProgress(j)}
1818
- >
1819
- <Gauge size={14} /> Check progress
1820
- </button>
1821
- <a
1822
- className="button secondary"
1823
- href={`https://huggingface.co/jobs/${j.owner}/${j.id}`}
1824
- target="_blank"
1825
- rel="noreferrer"
1826
- >
1827
- <Terminal size={14} /> Logs <ArrowUpRight size={13} />
1828
- </a>
1829
- {j.output && (
1830
- <a
1831
- className="button secondary"
1832
- href={`https://huggingface.co/datasets/${j.output}`}
1833
- target="_blank"
1834
- rel="noreferrer"
1835
- >
1836
- <ArrowDownToLine size={14} /> Results{" "}
1837
- <ArrowUpRight size={13} />
1838
- </a>
1839
- )}
1840
- {!terminal.has(j.stage) && (
1841
- <button
1842
- className="text-danger"
1843
- onClick={() => setCancelId(j.id)}
1844
- >
1845
- Cancel job
1846
- </button>
1847
- )}
1848
- </div>
1849
- </article>
1850
- ))}
1851
- </div>
1852
- )}
1853
- <p className="section-footnote">
1854
- Show annotations opens the visualizer’s Annotations tab for an
1855
- annotated dataset copy. Sign in there to access private results.
1856
- Annotation-file exports can be downloaded from Results. Only
1857
- jobs launched from Annotation Studio appear here. A completed
1858
- job can contain partial results; check the saved run report.
1859
- </p>
1860
- </>
1861
  )}
1862
  {page === "guide" && (
1863
  <>
@@ -1939,15 +1569,6 @@ function App() {
1939
  </div>
1940
  </>
1941
  )}
1942
- <footer>
1943
- <span>
1944
- <Bot size={14} /> LeRobot Annotation Studio
1945
- </span>
1946
- <span>
1947
- Built on Hugging Face <span className="footer-dot">·</span> Open
1948
- source robotics
1949
- </span>
1950
- </footer>
1951
  </main>
1952
  </div>
1953
  <dialog
@@ -2072,9 +1693,7 @@ function App() {
2072
  </button>
2073
  </div>
2074
  <div className="modal-body">
2075
- <div className="overline">READY WHEN YOU ARE</div>
2076
  <h2>Review your annotation job</h2>
2077
- <p>Confirm the details before starting paid compute.</p>
2078
  {error && (
2079
  <div className="message error" role="alert">
2080
  {error}
@@ -2151,23 +1770,11 @@ function App() {
2151
  onChange={(e) => setConsent(e.target.checked)}
2152
  />
2153
  <span>
2154
- I authorize job and inference charges to my HF account and sending
2155
- selected video frames to{" "}
2156
- {quote?.model_profile.provider_name || "the selected provider"}. I
2157
- have permission to process this dataset.
2158
- {visibility === "public" &&
2159
- (options.mode === COPY
2160
- ? " I also authorize publishing the copied episodes, including their videos and annotations, publicly."
2161
- : " I also authorize publishing the annotation export, including its source reference and run details, publicly.")}
2162
  </span>
2163
  </label>
2164
- <p className="microcopy">
2165
- The spending guard may stop a run early. Storage charges are
2166
- separate.{" "}
2167
- {options.mode === COPY
2168
- ? "Only copied episodes use your dataset storage quota."
2169
- : ""}
2170
- </p>
2171
  <button
2172
  className="button primary full"
2173
  disabled={!consent || submitting}
 
1
  import { useEffect, useRef, useState } from "react";
2
  import { createRoot } from "react-dom/client";
3
  import {
 
4
  ArrowLeft,
5
  ArrowRight,
6
  ArrowUpRight,
 
11
  Check,
12
  CheckCheck,
13
  ChevronDown,
 
14
  Coins,
15
  Database,
16
  FileJson2,
17
  FolderOpen,
 
 
18
  Globe,
19
  HelpCircle,
20
  Layers3,
 
30
  Rocket,
31
  Search,
32
  Settings2,
 
33
  Sun,
 
34
  Video,
35
  WandSparkles,
36
  X,
 
38
  import "./style.css";
39
  import "./wizard.css";
40
  import AnnotationExamples from "./AnnotationExamples";
41
+ import JobsView, { type Job, type Progress } from "./JobsView";
42
 
43
  type Feature =
44
  | "Subtasks"
 
122
  csrf: string;
123
  auth_available: boolean;
124
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  const EXPORT = "Annotation files only",
126
  COPY = "Annotated dataset copy";
127
  const defaults: Options = {
 
202
  const num = (n: number) => new Intl.NumberFormat("en-US").format(n);
203
  const duration = (s: number) =>
204
  s >= 3600 ? `${(s / 3600).toFixed(1)} hours` : `${(s / 60).toFixed(1)} min`;
 
205
  function initial() {
206
  try {
207
  return {
 
700
  <BookOpen size={18} /> How it works
701
  </button>
702
  </nav>
 
 
 
 
 
 
 
 
 
 
703
  </aside>
704
  <div className="workspace">
705
  <header className="topbar">
 
711
  >
712
  <Menu size={20} />
713
  </button>
 
 
714
  <strong>
715
  {page === "new"
716
  ? "New annotation"
 
773
  <div className="page-heading">
774
  <div>
775
  <h1>New annotation</h1>
 
776
  </div>
777
  </div>
778
  <nav className="wizard-steps" aria-label="Annotation setup">
 
804
  ))}
805
  </nav>
806
  <div className="step-intro">
 
807
  <h2 ref={stepHeading} tabIndex={-1}>
808
  {
809
  [
 
814
  ][step]
815
  }
816
  </h2>
 
 
 
 
 
 
 
 
 
 
817
  </div>
818
  {step === 0 && (
819
  <section
 
821
  aria-label="Dataset settings"
822
  >
823
  <label className="field-label" htmlFor="dataset">
824
+ Dataset ID
825
  </label>
826
  <div className="dataset-input">
827
  <Database size={17} />
 
836
  {metadata && <Check size={18} className="success-text" />}
837
  </div>
838
  <div className="field-help">
839
+ Sign in to access private datasets.
 
840
  </div>
841
  <div className="fields-row">
842
  <label>
 
1046
 
1047
  {selectedModel && (
1048
  <>
 
 
 
1049
  <div className="model-overview">
1050
  <span>{selectedModel.provider_name}</span>
1051
  <span>
 
1202
  </label>
1203
  ))}
1204
  </div>
 
 
 
 
 
 
 
 
1205
  <fieldset className="visibility-control">
1206
  <legend>Repository visibility</legend>
1207
  <div className="visibility-options">
 
1233
  </label>
1234
  ))}
1235
  </div>
 
 
 
 
 
 
 
 
 
1236
  </fieldset>
1237
  <button
1238
  className="disclosure"
 
1275
  </select>
1276
  </label>
1277
  </div>
 
 
 
 
 
 
 
 
1278
  </div>
1279
  )}
1280
  </section>
 
1293
  <span>–</span>
1294
  {money(quote.costs.high)}
1295
  </div>
 
 
 
1296
  <details className="progressive-details cost-breakdown">
1297
  <summary>
1298
  Cost breakdown <ChevronDown size={15} />
 
1390
  />
1391
  </div>
1392
  <p className="microcopy">
1393
+ Stops near this target; completed results are kept.
 
1394
  </p>
1395
  {quote && budget < quote.costs.high && (
1396
  <div className="budget-hint">
 
1427
  <Rocket size={16} /> Review & launch{" "}
1428
  <ArrowRight size={16} />
1429
  </button>
 
 
 
1430
  </>
1431
  ) : (
1432
+ <>{signIn}</>
 
 
 
 
 
1433
  )}
1434
  </div>
 
 
 
 
 
 
 
 
1435
  </section>
1436
  </div>
1437
  )}
 
1445
  <ArrowLeft size={15} /> Back
1446
  </button>
1447
  ) : (
1448
+ <span />
 
 
1449
  )}
1450
  {step < 3 && (
1451
  <button
 
1477
  </div>
1478
  )}
1479
  {page === "jobs" && (
1480
+ <JobsView
1481
+ jobs={jobs}
1482
+ progress={progress}
1483
+ models={modelCatalog?.models || []}
1484
+ signedIn={!!session?.user}
1485
+ loading={jobsLoading}
1486
+ signIn={signIn}
1487
+ onRefresh={loadJobs}
1488
+ onNew={() => navigate("new")}
1489
+ onCancel={setCancelId}
1490
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1491
  )}
1492
  {page === "guide" && (
1493
  <>
 
1569
  </div>
1570
  </>
1571
  )}
 
 
 
 
 
 
 
 
 
1572
  </main>
1573
  </div>
1574
  <dialog
 
1693
  </button>
1694
  </div>
1695
  <div className="modal-body">
 
1696
  <h2>Review your annotation job</h2>
 
1697
  {error && (
1698
  <div className="message error" role="alert">
1699
  {error}
 
1770
  onChange={(e) => setConsent(e.target.checked)}
1771
  />
1772
  <span>
1773
+ I approve this run and its charges, using{" "}
1774
+ {quote?.model_profile.provider_name || "the selected provider"}.
 
 
 
 
 
 
1775
  </span>
1776
  </label>
1777
+ <p className="microcopy">Storage is billed separately.</p>
 
 
 
 
 
 
1778
  <button
1779
  className="button primary full"
1780
  disabled={!consent || submitting}