AdithyaSK HF Staff Claude Opus 4.7 (1M context) commited on
Commit
78d005c
Β·
1 Parent(s): 110bf99

Unify dataset+task into one master-detail workspace; UI polish

Browse files

- Merge the dataset task-list view and the task detail view into a single
workspace: tasks side-panel (master) + tree/content (detail), in-place
task switching, empty "select a task" state when none chosen
- Home: curated example bubbles (HF + GitHub) instead of the live table;
no longer leads with a specific dataset
- Datasets tab: example quick-pick chips above the searchable Hub list
- Minimal thin scrollbars; terminal-styled run command; wider layout (1500px)
- Remove the top accent strip; deep-link/badge use the real $SPACE_HOST

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. static/app.js +86 -95
  2. static/style.css +29 -5
static/app.js CHANGED
@@ -30,6 +30,7 @@ const ICON = {
30
  next: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>',
31
  term: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-6-6-6M12 19h8"/></svg>',
32
  panel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>',
 
33
  };
34
 
35
  function copyButton(text, cls = 'copy') {
@@ -45,6 +46,31 @@ function copyButton(text, cls = 'copy') {
45
  return b;
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  /* ── theme ────────────────────────────────────────── */
49
  function applyTheme(mode) {
50
  const sys = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
@@ -118,15 +144,11 @@ async function renderHome() {
118
  <input id="load-input" placeholder="Load any dataset β€” owner/name Β· hf:// Β· gh://owner/repo Β· harbor://org/name Β· /local/path" />
119
  <span class="kbd">↡</span>
120
  </div>
121
- <div style="display:flex;align-items:center;justify-content:space-between;margin:26px 0 12px">
122
- <h2 style="margin:0">Harbor datasets on the Hub</h2>
123
- <span class="faint" id="hub-status">loading…</span>
124
- </div>
125
- <div class="card" id="hub-table">
126
- <div class="thead"><span>Dataset</span><span class="col-tasks">Tasks</span></div>
127
- <div class="loading"><span class="spinner"></span>fetching from huggingface.co/datasets?other=harbor</div>
128
  </div>
129
- <div class="center"><a class="btn" href="#/datasets">View all datasets β†’</a></div>
130
 
131
  <div class="howto">
132
  <h2>Link your dataset to the visualiser</h2>
@@ -153,22 +175,11 @@ async function renderHome() {
153
  `;
154
  document.getElementById('copy-link').appendChild(copyButton(`${origin}/?dataset=<owner>/<name>`));
155
  document.getElementById('copy-badge').appendChild(copyButton(badgeMd));
 
156
  const input = document.getElementById('load-input');
157
  input.addEventListener('keydown', (e) => {
158
  if (e.key === 'Enter' && input.value.trim()) location.hash = `dataset?uri=${enc(input.value.trim())}`;
159
  });
160
-
161
- try {
162
- const { datasets } = await api('/api/hub/datasets?sort=downloads&limit=12');
163
- const card = document.getElementById('hub-table');
164
- card.innerHTML = '<div class="thead"><span>Dataset</span><span class="col-tasks">Tasks</span></div>';
165
- datasets.slice(0, 8).forEach(d => card.appendChild(datasetRow(d.id, null)));
166
- document.getElementById('hub-status').textContent = `${datasets.length}+ datasets`;
167
- fillCounts(card);
168
- } catch (e) {
169
- document.getElementById('hub-table').innerHTML = `<div class="errbox">Couldn't reach the Hub: ${esc(e.message)}</div>`;
170
- document.getElementById('hub-status').textContent = '';
171
- }
172
  }
173
 
174
  let _hubCache = null;
@@ -178,7 +189,8 @@ async function renderDatasets(params) {
178
  APP.innerHTML = `
179
  <div class="page">
180
  <h1>Datasets</h1>
181
- <p class="muted" style="margin:-8px 0 20px;font-size:13.5px">Search across every <strong style="color:var(--text)">Harbor-tagged dataset on the Hugging&nbsp;Face&nbsp;Hub</strong> β€” the live <code style="background:var(--panel-2);padding:1px 6px;border-radius:4px">other=harbor</code> filter.</p>
 
182
  <div class="search">
183
  ${ICON.search}
184
  <input id="ds-search" placeholder="Search Harbor datasets on the Hub…" autofocus />
@@ -195,6 +207,7 @@ async function renderDatasets(params) {
195
  <span><strong style="color:var(--text)">Want your dataset to show up here?</strong> Add the <code>harbor</code> tag to your dataset card's metadata (<code>tags: [harbor]</code> in the README front-matter) and it'll appear in this list automatically.</span>
196
  </div>
197
  </div>`;
 
198
  const tbl = document.getElementById('ds-table');
199
  const search = document.getElementById('ds-search');
200
  const sortSel = document.getElementById('ds-sort'); sortSel.value = sort;
@@ -225,60 +238,6 @@ async function renderDatasets(params) {
225
  await load();
226
  }
227
 
228
- async function renderDataset(params) {
229
- setActiveNav(null);
230
- const uri = params.get('uri');
231
- APP.innerHTML = `
232
- <div class="page">
233
- <div class="crumb"><a href="#/datasets">Datasets</a><span class="sep">/</span><span>${esc(uri)}</span></div>
234
- <div class="loading"><span class="spinner"></span>fetching <b>${esc(uri)}</b> …
235
- <span class="sub">Loading the Harbor spec β€” this can take a few seconds to a minute for large datasets (the more tasks, the longer the listing).</span>
236
- </div>
237
- </div>`;
238
- let data;
239
- try { data = await api(`/api/dataset?uri=${enc(uri)}`); }
240
- catch (e) { APP.querySelector('.page').innerHTML = `<div class="crumb"><a href="#/datasets">Datasets</a></div><div class="errbox">Failed to load <b>${esc(uri)}</b>: ${esc(e.message)}</div>`; return; }
241
-
242
- const page = APP.querySelector('.page');
243
- page.innerHTML = `
244
- <div class="crumb"><a href="#/datasets">Datasets</a><span class="sep">/</span><span>${esc(data.display)}</span>
245
- <span class="pill">${data.count} tasks</span>
246
- <button class="btn" id="refresh" style="margin-left:auto;padding:5px 11px;font-size:12px">↻ refresh</button>
247
- </div>
248
- <div class="search"><span style="color:var(--faint)">${ICON.search}</span>
249
- <input id="task-search" placeholder="Search ${data.count} tasks…" autofocus /></div>
250
- <div class="card tasklist" id="tasks"></div>`;
251
- const tasksCard = document.getElementById('tasks');
252
- const tsearch = document.getElementById('task-search');
253
- function draw(list) {
254
- tasksCard.innerHTML = '<div class="thead"><span>Task</span></div>';
255
- if (!list.length) { tasksCard.innerHTML += '<div class="empty">no matching tasks</div>'; return; }
256
- list.slice(0, 500).forEach(tid => {
257
- const row = document.createElement('div'); row.className = 'row';
258
- row.onclick = () => { location.hash = `task?${qs({ uri, task: tid })}`; };
259
- row.innerHTML = `<span class="name">${esc(tid)}</span>`;
260
- row.appendChild(copyButton(tid));
261
- tasksCard.appendChild(row);
262
- });
263
- if (list.length > 500) tasksCard.innerHTML += `<div class="empty">showing 500 of ${list.length} β€” refine your search</div>`;
264
- }
265
- draw(data.tasks);
266
- let t;
267
- tsearch.addEventListener('input', () => {
268
- clearTimeout(t);
269
- t = setTimeout(() => {
270
- const q = tsearch.value.trim().toLowerCase();
271
- draw(q ? data.tasks.filter(x => x.toLowerCase().includes(q)) : data.tasks);
272
- }, 100);
273
- });
274
- document.getElementById('refresh').onclick = async () => {
275
- page.querySelector('.crumb').insertAdjacentHTML('beforeend', ' <span class="faint">refreshing…</span>');
276
- try { const fresh = await api(`/api/dataset?${qs({ uri, refresh: 1 })}`); data.tasks = fresh.tasks; draw(fresh.tasks); }
277
- catch (e) { alert('refresh failed: ' + e.message); }
278
- location.reload();
279
- };
280
- }
281
-
282
  /* ── task viewer (file tree + content) ────────────── */
283
  const LANG = { toml: 'ini', diff: 'diff', patch: 'diff', sh: 'bash', bash: 'bash', py: 'python', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown', js: 'javascript', ts: 'typescript', html: 'xml', css: 'css' };
284
  function langFor(path) {
@@ -295,40 +254,49 @@ function harborCmd(kind, ident, taskId) {
295
  return `huggingface-cli download ${ident} --repo-type dataset --local-dir ${dir} && harbor run -p ${dir} -i ${taskId} -a oracle`;
296
  }
297
 
 
 
 
298
  let _taskSiblings = { uri: null, tasks: [], ident: null, kind: null };
299
- async function renderTask(params) {
300
- setActiveNav(null);
301
  const uri = params.get('uri');
302
- let task = params.get('task');
303
  let initialFile = params.get('f');
304
 
305
- APP.innerHTML = `<div class="page"><div class="loading"><span class="spinner"></span>loading task…
306
- <span class="sub">Fetching this task's files from the Hub β€” usually a second or two.</span>
307
  </div></div>`;
308
 
309
- // Sibling task list (for the side panel) + canonical ident/kind (run command).
310
  // Cached per-uri so flipping between tasks doesn't refetch the list.
311
  if (_taskSiblings.uri !== uri) {
312
  try {
313
  const ds = await api(`/api/dataset?uri=${enc(uri)}`);
314
  _taskSiblings = { uri, tasks: ds.tasks || [], ident: ds.ident, kind: ds.kind };
315
- } catch { _taskSiblings = { uri, tasks: [], ident: uri, kind: 'hf' }; }
 
 
 
316
  }
317
  const siblings = _taskSiblings.tasks;
318
  const ident = _taskSiblings.ident || uri;
319
  const kind = _taskSiblings.kind || 'hf';
320
 
321
  const page = APP.querySelector('.page');
322
- const collapsed = localStorage.getItem('hh-tasks-collapsed') === '1';
 
323
  page.innerHTML = `
324
  <div class="crumb">
325
  <button class="nav-btn ghost" id="toggle-tasks" title="Toggle task list">${ICON.panel}</button>
326
- <a href="#dataset?uri=${enc(uri)}">${esc(ident)}</a>
327
- <span class="sep">/</span><span id="crumb-task">${esc(task)}</span>
328
- <span id="crumb-diff"></span>
 
329
  <span class="pos" id="crumb-pos" style="margin-left:auto"></span>
 
330
  </div>
331
- <div class="runbar">
332
  <span class="lbl">${ICON.term}</span>
333
  <code id="run-cmd"></code>
334
  <span id="run-copy"></span>
@@ -336,7 +304,7 @@ async function renderTask(params) {
336
  <div class="taskview${collapsed ? ' collapsed' : ''}" id="taskview">
337
  <div class="tasks-panel" id="tasks-panel">
338
  <div class="tp-head">Tasks <span class="faint">${siblings.length}</span></div>
339
- <div class="tp-search">${ICON.search}<input id="tp-search" placeholder="Filter tasks…" /></div>
340
  <div class="tp-list" id="tp-list"></div>
341
  </div>
342
  <div class="tree" id="tree"></div>
@@ -347,7 +315,7 @@ async function renderTask(params) {
347
  const tpList = document.getElementById('tp-list');
348
  const tree = document.getElementById('tree');
349
  const content = document.getElementById('content');
350
- const runbar = page.querySelector('.runbar');
351
  const runCode = document.getElementById('run-cmd');
352
  const runCopyHolder = document.getElementById('run-copy');
353
 
@@ -355,6 +323,13 @@ async function renderTask(params) {
355
  taskview.classList.toggle('collapsed');
356
  localStorage.setItem('hh-tasks-collapsed', taskview.classList.contains('collapsed') ? '1' : '0');
357
  };
 
 
 
 
 
 
 
358
 
359
  // ── tasks side panel ──
360
  function drawPanel(filter = '') {
@@ -383,16 +358,31 @@ async function renderTask(params) {
383
  const a = tpList.querySelector('.tp-item.active'); if (a) a.scrollIntoView({ block: 'nearest' });
384
  }
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  // ── load one task's detail into the tree + content (no full re-render) ──
387
  async function loadDetail(tid, wantFile) {
388
  task = tid;
389
  syncPanelActive(tid);
390
- document.getElementById('crumb-task').textContent = tid;
 
391
  const i = siblings.indexOf(tid);
392
  document.getElementById('crumb-pos').textContent = i >= 0 ? `${i + 1} / ${siblings.length}` : '';
393
  history.replaceState(null, '', '#' + `task?${qs({ uri, task: tid })}`);
394
 
395
  const cmd = harborCmd(kind, ident, tid);
 
396
  runCode.textContent = cmd;
397
  runCopyHolder.innerHTML = '';
398
  const rc = copyButton(cmd);
@@ -406,7 +396,8 @@ async function renderTask(params) {
406
  catch (e) { content.innerHTML = `<div class="errbox">${esc(e.message)}</div>`; return; }
407
  if (task !== tid) return; // a newer click superseded this fetch
408
 
409
- document.getElementById('crumb-diff').innerHTML = t.difficulty ? `<span class="pill">${esc(t.difficulty)}</span>` : '';
 
410
  buildDetail(t, wantFile);
411
  }
412
 
@@ -479,7 +470,8 @@ async function renderTask(params) {
479
  select(wantFile && (nodes[wantFile] || wantFile === '__overview__') ? wantFile : '__overview__');
480
  }
481
 
482
- await loadDetail(task, initialFile);
 
483
  }
484
 
485
  /* ── router ───────────────────────────────────────── */
@@ -490,8 +482,7 @@ function router() {
490
  window.scrollTo(0, 0);
491
  if (route === '/' || route === '' || route === 'home') return renderHome();
492
  if (route === '/datasets' || route === 'datasets') return renderDatasets(params);
493
- if (route === 'dataset') return renderDataset(params);
494
- if (route === 'task') return renderTask(params);
495
  renderHome();
496
  }
497
 
@@ -499,7 +490,7 @@ function router() {
499
  document.addEventListener('keydown', (e) => {
500
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
501
  e.preventDefault();
502
- const s = document.getElementById('ds-search') || document.getElementById('load-input');
503
  if (s) s.focus(); else location.hash = '/datasets';
504
  }
505
  });
 
30
  next: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>',
31
  term: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-6-6-6M12 19h8"/></svg>',
32
  panel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>',
33
+ refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>',
34
  };
35
 
36
  function copyButton(text, cls = 'copy') {
 
46
  return b;
47
  }
48
 
49
+ /* ── curated example datasets (shown as bubbles) ──── */
50
+ const EXAMPLES = [
51
+ { label: 'Terminal-Bench 2.0', uri: 'harborframework/terminal-bench-2.0' },
52
+ { label: 'TaskTrove', uri: 'open-thoughts/TaskTrove' },
53
+ { label: 'Repo2RLEnv Β· PR diffs', uri: 'AdithyaSK/repo2rlenv-v083-pr_diff' },
54
+ { label: 'TitanBench', uri: 'billshockley/titanbench' },
55
+ { label: 'Harbor tasks demo', uri: 'gh://adithya-s-k/harbor-tasks-demo' },
56
+ ];
57
+ function srcTag(uri) {
58
+ if (uri.startsWith('gh://') || uri.includes('github.com')) return 'gh';
59
+ if (uri.startsWith('harbor://')) return 'harbor';
60
+ return 'hf';
61
+ }
62
+ function exampleChips() {
63
+ const wrap = document.createElement('div'); wrap.className = 'chips';
64
+ EXAMPLES.forEach(ex => {
65
+ const tag = srcTag(ex.uri);
66
+ const c = document.createElement('button'); c.className = 'chip'; c.title = ex.uri;
67
+ c.innerHTML = `<span class="src ${tag}">${tag}</span><span>${esc(ex.label)}</span>`;
68
+ c.onclick = () => { location.hash = `dataset?uri=${enc(ex.uri)}`; };
69
+ wrap.appendChild(c);
70
+ });
71
+ return wrap;
72
+ }
73
+
74
  /* ── theme ────────────────────────────────────────── */
75
  function applyTheme(mode) {
76
  const sys = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
 
144
  <input id="load-input" placeholder="Load any dataset β€” owner/name Β· hf:// Β· gh://owner/repo Β· harbor://org/name Β· /local/path" />
145
  <span class="kbd">↡</span>
146
  </div>
147
+ <div style="display:flex;align-items:center;justify-content:space-between;margin:30px 0 14px">
148
+ <h2 style="margin:0">Examples</h2>
149
+ <a class="faint hl" href="#/datasets" style="font-size:13px">Browse all Harbor datasets β†’</a>
 
 
 
 
150
  </div>
151
+ <div id="examples"></div>
152
 
153
  <div class="howto">
154
  <h2>Link your dataset to the visualiser</h2>
 
175
  `;
176
  document.getElementById('copy-link').appendChild(copyButton(`${origin}/?dataset=<owner>/<name>`));
177
  document.getElementById('copy-badge').appendChild(copyButton(badgeMd));
178
+ document.getElementById('examples').appendChild(exampleChips());
179
  const input = document.getElementById('load-input');
180
  input.addEventListener('keydown', (e) => {
181
  if (e.key === 'Enter' && input.value.trim()) location.hash = `dataset?uri=${enc(input.value.trim())}`;
182
  });
 
 
 
 
 
 
 
 
 
 
 
 
183
  }
184
 
185
  let _hubCache = null;
 
189
  APP.innerHTML = `
190
  <div class="page">
191
  <h1>Datasets</h1>
192
+ <p class="muted" style="margin:-8px 0 18px;font-size:13.5px">Search across every <strong style="color:var(--text)">Harbor-tagged dataset on the Hugging&nbsp;Face&nbsp;Hub</strong> β€” the live <code style="background:var(--panel-2);padding:1px 6px;border-radius:4px">other=harbor</code> filter.</p>
193
+ <div id="ds-examples" style="margin-bottom:20px"></div>
194
  <div class="search">
195
  ${ICON.search}
196
  <input id="ds-search" placeholder="Search Harbor datasets on the Hub…" autofocus />
 
207
  <span><strong style="color:var(--text)">Want your dataset to show up here?</strong> Add the <code>harbor</code> tag to your dataset card's metadata (<code>tags: [harbor]</code> in the README front-matter) and it'll appear in this list automatically.</span>
208
  </div>
209
  </div>`;
210
+ document.getElementById('ds-examples').appendChild(exampleChips());
211
  const tbl = document.getElementById('ds-table');
212
  const search = document.getElementById('ds-search');
213
  const sortSel = document.getElementById('ds-sort'); sortSel.value = sort;
 
238
  await load();
239
  }
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  /* ── task viewer (file tree + content) ────────────── */
242
  const LANG = { toml: 'ini', diff: 'diff', patch: 'diff', sh: 'bash', bash: 'bash', py: 'python', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown', js: 'javascript', ts: 'typescript', html: 'xml', css: 'css' };
243
  function langFor(path) {
 
254
  return `huggingface-cli download ${ident} --repo-type dataset --local-dir ${dir} && harbor run -p ${dir} -i ${taskId} -a oracle`;
255
  }
256
 
257
+ // Unified dataset+task workspace: tasks side-panel (master) + file tree + content (detail).
258
+ // Used by BOTH the `dataset?uri=` route (no task β†’ empty detail) and the
259
+ // `task?uri=&task=` route (task preselected). Switching tasks happens in place.
260
  let _taskSiblings = { uri: null, tasks: [], ident: null, kind: null };
261
+ async function renderWorkspace(params) {
262
+ setActiveNav('datasets');
263
  const uri = params.get('uri');
264
+ let task = params.get('task') || null;
265
  let initialFile = params.get('f');
266
 
267
+ APP.innerHTML = `<div class="page"><div class="loading"><span class="spinner"></span>loading…
268
+ <span class="sub">Loading the Harbor spec β€” a few seconds to a minute for large datasets (the more tasks, the longer the listing).</span>
269
  </div></div>`;
270
 
271
+ // Sibling task list (the panel) + canonical ident/kind (run command).
272
  // Cached per-uri so flipping between tasks doesn't refetch the list.
273
  if (_taskSiblings.uri !== uri) {
274
  try {
275
  const ds = await api(`/api/dataset?uri=${enc(uri)}`);
276
  _taskSiblings = { uri, tasks: ds.tasks || [], ident: ds.ident, kind: ds.kind };
277
+ } catch (e) {
278
+ APP.querySelector('.page').innerHTML = `<div class="crumb"><a href="#/datasets">Datasets</a></div><div class="errbox">Failed to load <b>${esc(uri)}</b>: ${esc(e.message)}</div>`;
279
+ return;
280
+ }
281
  }
282
  const siblings = _taskSiblings.tasks;
283
  const ident = _taskSiblings.ident || uri;
284
  const kind = _taskSiblings.kind || 'hf';
285
 
286
  const page = APP.querySelector('.page');
287
+ // Honour the collapse pref only when a task is open; with no task the panel is the focus.
288
+ const collapsed = task && localStorage.getItem('hh-tasks-collapsed') === '1';
289
  page.innerHTML = `
290
  <div class="crumb">
291
  <button class="nav-btn ghost" id="toggle-tasks" title="Toggle task list">${ICON.panel}</button>
292
+ <a href="#/datasets">Datasets</a><span class="sep">/</span>
293
+ <a href="#dataset?uri=${enc(uri)}" id="crumb-ident">${esc(ident)}</a>
294
+ <span class="pill">${siblings.length} tasks</span>
295
+ <span id="crumb-task-wrap"></span>
296
  <span class="pos" id="crumb-pos" style="margin-left:auto"></span>
297
+ <button class="nav-btn ghost" id="ws-refresh" title="Refresh dataset">${ICON.refresh}</button>
298
  </div>
299
+ <div class="runbar" id="runbar" hidden>
300
  <span class="lbl">${ICON.term}</span>
301
  <code id="run-cmd"></code>
302
  <span id="run-copy"></span>
 
304
  <div class="taskview${collapsed ? ' collapsed' : ''}" id="taskview">
305
  <div class="tasks-panel" id="tasks-panel">
306
  <div class="tp-head">Tasks <span class="faint">${siblings.length}</span></div>
307
+ <div class="tp-search">${ICON.search}<input id="tp-search" placeholder="Filter tasks…" autofocus /></div>
308
  <div class="tp-list" id="tp-list"></div>
309
  </div>
310
  <div class="tree" id="tree"></div>
 
315
  const tpList = document.getElementById('tp-list');
316
  const tree = document.getElementById('tree');
317
  const content = document.getElementById('content');
318
+ const runbar = document.getElementById('runbar');
319
  const runCode = document.getElementById('run-cmd');
320
  const runCopyHolder = document.getElementById('run-copy');
321
 
 
323
  taskview.classList.toggle('collapsed');
324
  localStorage.setItem('hh-tasks-collapsed', taskview.classList.contains('collapsed') ? '1' : '0');
325
  };
326
+ document.getElementById('ws-refresh').onclick = async () => {
327
+ try {
328
+ const ds = await api(`/api/dataset?${qs({ uri, refresh: 1 })}`);
329
+ _taskSiblings = { uri, tasks: ds.tasks || [], ident: ds.ident, kind: ds.kind };
330
+ } catch (e) { alert('refresh failed: ' + e.message); return; }
331
+ renderWorkspace(params);
332
+ };
333
 
334
  // ── tasks side panel ──
335
  function drawPanel(filter = '') {
 
358
  const a = tpList.querySelector('.tp-item.active'); if (a) a.scrollIntoView({ block: 'nearest' });
359
  }
360
 
361
+ // Empty (no task selected) β€” master-detail "nothing selected" state.
362
+ function showEmpty() {
363
+ task = null;
364
+ runbar.hidden = true;
365
+ syncPanelActive(null);
366
+ document.getElementById('crumb-task-wrap').innerHTML = '';
367
+ document.getElementById('crumb-pos').textContent = '';
368
+ history.replaceState(null, '', '#' + `dataset?${qs({ uri })}`);
369
+ tree.innerHTML = '';
370
+ content.innerHTML = `<div class="emptysel"><div class="ic">${ICON.panel}</div>
371
+ <p>Select a task from the list to view its spec, files & run command.</p></div>`;
372
+ }
373
+
374
  // ── load one task's detail into the tree + content (no full re-render) ──
375
  async function loadDetail(tid, wantFile) {
376
  task = tid;
377
  syncPanelActive(tid);
378
+ document.getElementById('crumb-task-wrap').innerHTML =
379
+ `<span class="sep">/</span><span id="crumb-task">${esc(tid)}</span><span id="crumb-diff"></span>`;
380
  const i = siblings.indexOf(tid);
381
  document.getElementById('crumb-pos').textContent = i >= 0 ? `${i + 1} / ${siblings.length}` : '';
382
  history.replaceState(null, '', '#' + `task?${qs({ uri, task: tid })}`);
383
 
384
  const cmd = harborCmd(kind, ident, tid);
385
+ runbar.hidden = false;
386
  runCode.textContent = cmd;
387
  runCopyHolder.innerHTML = '';
388
  const rc = copyButton(cmd);
 
396
  catch (e) { content.innerHTML = `<div class="errbox">${esc(e.message)}</div>`; return; }
397
  if (task !== tid) return; // a newer click superseded this fetch
398
 
399
+ const diffEl = document.getElementById('crumb-diff');
400
+ if (diffEl) diffEl.innerHTML = t.difficulty ? ` <span class="pill">${esc(t.difficulty)}</span>` : '';
401
  buildDetail(t, wantFile);
402
  }
403
 
 
470
  select(wantFile && (nodes[wantFile] || wantFile === '__overview__') ? wantFile : '__overview__');
471
  }
472
 
473
+ if (task) await loadDetail(task, initialFile);
474
+ else showEmpty();
475
  }
476
 
477
  /* ── router ───────────────────────────────────────── */
 
482
  window.scrollTo(0, 0);
483
  if (route === '/' || route === '' || route === 'home') return renderHome();
484
  if (route === '/datasets' || route === 'datasets') return renderDatasets(params);
485
+ if (route === 'dataset' || route === 'task') return renderWorkspace(params);
 
486
  renderHome();
487
  }
488
 
 
490
  document.addEventListener('keydown', (e) => {
491
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
492
  e.preventDefault();
493
+ const s = document.getElementById('tp-search') || document.getElementById('ds-search') || document.getElementById('load-input');
494
  if (s) s.focus(); else location.hash = '/datasets';
495
  }
496
  });
static/style.css CHANGED
@@ -8,7 +8,7 @@
8
  --ok: #16a34a; --warn: #d97706; --err: #dc2626;
9
  --hover: #f1f3f5;
10
  --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
11
- --radius: 10px; --nav-h: 56px; --maxw: 1180px;
12
  }
13
  :root[data-theme="dark"] {
14
  --bg: #0b0d12; --panel: #11141b; --panel-2: #1a1e27;
@@ -21,6 +21,14 @@
21
  }
22
  * { box-sizing: border-box; }
23
  html, body { margin: 0; padding: 0; overflow-x: hidden; max-width: 100%; }
 
 
 
 
 
 
 
 
24
  body {
25
  background: var(--bg); color: var(--text);
26
  font-family: var(--mono); font-size: 14px; line-height: 1.55;
@@ -64,10 +72,6 @@ h2 { font-size: 18px; font-weight: 600; margin: 0 0 12px; }
64
  .muted { color: var(--muted); }
65
  .faint { color: var(--faint); }
66
 
67
- /* thin Hugging Face accent strip at the very top */
68
- body::before { content: ""; display: block; height: 3px;
69
- background: linear-gradient(90deg, var(--hf-yellow), var(--hf-orange)); }
70
-
71
  /* hero */
72
  .hero { text-align: center; padding: 60px 0 30px; }
73
  .hero .mark { font-size: 56px; line-height: 1; margin-bottom: 10px; }
@@ -76,6 +80,19 @@ body::before { content: ""; display: block; height: 3px;
76
  -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
77
  .hero p { color: var(--muted); font-size: 14.5px; margin: 0 auto; max-width: 640px; line-height: 1.6; }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  /* how-to / embed instructions */
80
  .howto { margin: 46px 0 0; }
81
  .howto h2 { font-size: 15px; margin: 0 0 14px; }
@@ -187,6 +204,13 @@ body::before { content: ""; display: block; height: 3px;
187
  .tp-item.active { background: var(--accent-soft); color: var(--text); border-left-color: var(--hf-orange); }
188
  .tp-list .empty { padding: 14px; font-size: 11.5px; color: var(--faint); }
189
 
 
 
 
 
 
 
 
190
  .content { overflow: auto; max-height: 80vh; background: var(--bg); }
191
  .taskview .tree, .taskview .content { max-height: none; }
192
  .content .fhead { display: flex; align-items: center; gap: 10px; padding: 10px 16px;
 
8
  --ok: #16a34a; --warn: #d97706; --err: #dc2626;
9
  --hover: #f1f3f5;
10
  --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
11
+ --radius: 10px; --nav-h: 56px; --maxw: 1500px;
12
  }
13
  :root[data-theme="dark"] {
14
  --bg: #0b0d12; --panel: #11141b; --panel-2: #1a1e27;
 
21
  }
22
  * { box-sizing: border-box; }
23
  html, body { margin: 0; padding: 0; overflow-x: hidden; max-width: 100%; }
24
+
25
+ /* minimal scrollbars everywhere */
26
+ * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
27
+ ::-webkit-scrollbar { width: 7px; height: 7px; }
28
+ ::-webkit-scrollbar-track { background: transparent; }
29
+ ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
30
+ ::-webkit-scrollbar-thumb:hover { background: var(--faint); background-clip: padding-box; }
31
+ ::-webkit-scrollbar-corner { background: transparent; }
32
  body {
33
  background: var(--bg); color: var(--text);
34
  font-family: var(--mono); font-size: 14px; line-height: 1.55;
 
72
  .muted { color: var(--muted); }
73
  .faint { color: var(--faint); }
74
 
 
 
 
 
75
  /* hero */
76
  .hero { text-align: center; padding: 60px 0 30px; }
77
  .hero .mark { font-size: 56px; line-height: 1; margin-bottom: 10px; }
 
80
  -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
81
  .hero p { color: var(--muted); font-size: 14.5px; margin: 0 auto; max-width: 640px; line-height: 1.6; }
82
 
83
+ /* example bubbles / quick-pick chips */
84
+ .chips { display: flex; flex-wrap: wrap; gap: 10px; }
85
+ .chip { display: inline-flex; align-items: center; gap: 9px; padding: 9px 14px 9px 11px; border-radius: 999px;
86
+ border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px;
87
+ cursor: pointer; transition: border-color .12s, background .12s, transform .08s; }
88
+ .chip:hover { border-color: var(--hf-orange); background: var(--hover); }
89
+ .chip:active { transform: translateY(1px); }
90
+ .chip .src { font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
91
+ padding: 2px 6px; border-radius: 5px; flex: none; }
92
+ .chip .src.hf { background: color-mix(in srgb, var(--hf-yellow) 22%, var(--panel-2)); color: var(--hf-orange); }
93
+ .chip .src.gh { background: var(--panel-2); color: var(--muted); }
94
+ .chip .src.harbor { background: var(--accent-soft); color: var(--accent); }
95
+
96
  /* how-to / embed instructions */
97
  .howto { margin: 46px 0 0; }
98
  .howto h2 { font-size: 15px; margin: 0 0 14px; }
 
204
  .tp-item.active { background: var(--accent-soft); color: var(--text); border-left-color: var(--hf-orange); }
205
  .tp-list .empty { padding: 14px; font-size: 11.5px; color: var(--faint); }
206
 
207
+ /* nothing-selected state in the detail pane */
208
+ .emptysel { display: flex; flex-direction: column; align-items: center; justify-content: center;
209
+ gap: 14px; height: 100%; min-height: 320px; padding: 40px; text-align: center; color: var(--faint); }
210
+ .emptysel .ic { color: var(--border-strong); line-height: 0; }
211
+ .emptysel .ic svg { width: 44px; height: 44px; }
212
+ .emptysel p { margin: 0; font-size: 13px; max-width: 320px; line-height: 1.6; }
213
+
214
  .content { overflow: auto; max-height: 80vh; background: var(--bg); }
215
  .taskview .tree, .taskview .content { max-height: none; }
216
  .content .fhead { display: flex; align-items: center; gap: 10px; padding: 10px 16px;