@njeybe/nativ 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/core/setup/pm-playbook.d.ts +1 -1
  3. package/dist/core/setup/pm-playbook.js +1 -1
  4. package/dist/server/studio-ui/pages/benchmarks/script.js +9 -8
  5. package/dist/server/studio-ui/pages/benchmarks/styles.js +3 -1
  6. package/dist/server/studio-ui/pages/database/browser.js +1 -1
  7. package/dist/server/studio-ui/pages/database/connection.js +5 -4
  8. package/dist/server/studio-ui/pages/database/data-shell.js +8 -13
  9. package/dist/server/studio-ui/pages/database/drift.js +1 -1
  10. package/dist/server/studio-ui/pages/database/events.js +1 -2
  11. package/dist/server/studio-ui/pages/database/explorer.js +4 -4
  12. package/dist/server/studio-ui/pages/flow/layout.js +1 -1
  13. package/dist/server/studio-ui/pages/flow/styles.js +1 -1
  14. package/dist/server/studio-ui/pages/flow/timeline-view.js +1 -1
  15. package/dist/server/studio-ui/pages/home/script.js +26 -29
  16. package/dist/server/studio-ui/pages/home/styles.js +2 -6
  17. package/dist/server/studio-ui/pages/tasks/script.js +25 -40
  18. package/dist/server/studio-ui/pages/tasks/styles.js +9 -9
  19. package/dist/server/studio-ui/pages/team/script.js +27 -8
  20. package/dist/server/studio-ui/pages/team/styles.js +0 -1
  21. package/dist/server/studio-ui/shell/console.js +13 -7
  22. package/dist/server/studio-ui/shell/detail-view.js +3 -3
  23. package/dist/server/studio-ui/shell/dispatch.js +9 -8
  24. package/dist/server/studio-ui/shell/events-tools.js +11 -0
  25. package/dist/server/studio-ui/shell/format.js +31 -2
  26. package/dist/server/studio-ui/shell/markup-head.js +1 -1
  27. package/dist/server/studio-ui/shell/markup-tools.js +13 -8
  28. package/dist/server/studio-ui/shell/model.js +8 -2
  29. package/dist/server/studio-ui/shell/paint.js +1 -3
  30. package/dist/server/studio-ui/shell/pipeline-state.js +2 -11
  31. package/dist/server/studio-ui/shell/pulse.js +8 -7
  32. package/dist/server/studio-ui/shell/sidebar-view.js +2 -3
  33. package/dist/server/studio-ui/shell/triage.js +1 -1
  34. package/dist/server/studio-ui/styles/console.js +3 -0
  35. package/dist/server/studio-ui/styles/shell.js +4 -6
  36. package/package.json +1 -1
  37. package/plugin/.claude-plugin/plugin.json +1 -1
  38. package/plugin/agents/architect.md +2 -2
  39. package/plugin/agents/verifier.md +4 -1
  40. package/plugin/skills/nativ/SKILL.md +7 -7
  41. package/templates/AGENTS.md +7 -7
  42. package/templates/GEMINI.md +1 -1
  43. package/templates/claude-agents/architect.md +2 -2
  44. package/templates/claude-agents/verifier.md +4 -1
  45. package/templates/dot-ai/subagents/architect.md +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0
4
+
5
+ ### Changed
6
+
7
+ - **One set of Studio state words.** Every page says Up next, Running, Done or Stuck. "Ready", "Blocked", "Waiting", "Just finished", "Idle" and "completed / started" are gone. A failed run shows as Stuck. A stopped run, or a claim older than 4 hours with no active run, shows as Up next.
8
+ - **Plain agent names.** Home, Tasks and Team show names such as "QA tester" instead of `qa-tester`. Team merges alias rows of the same agent.
9
+ - **Quieter shell.** The pulse strip shows only non-zero counts and hides when everything is zero. The dispatch button hides when nothing is ready instead of showing a grey "Nothing ready". "Abort" is now "Stop". The Benchmarks nav badge is gone. The workspace path no longer breaks mid-word.
10
+ - **Slimmer runner console.** Stop shows only while a run is active. Open diff, Clear and Auto-scroll sit in a More menu. An idle console hides its empty stepper and zero totals.
11
+ - **Tasks page.** A list row opens the task drawer, the same as a Board card. Filters with no tasks are hidden. The Board always shows its four lanes, and an empty lane says "None". The drawer has "Watch live", and "How it is checked" wraps.
12
+ - **Home.** Recent activity shows one row per task with its title and state, for example "Done, took 4m 00s".
13
+ - **Flow.** Plan cards show titles on up to two lines.
14
+ - **Database and Benchmarks.** "Not set up" is neutral, and red means only a configured connection that is offline. The header Connection Settings button is the single way in. Benchmarks lists scenarios that were not run on one line.
15
+ - **Option trade-offs.** The architect, the project manager and the shared directive add one line on when the non-recommended option would be the better choice.
16
+ - **Verifier report.** The verifier reports contract findings and code style findings in separate parts.
17
+
3
18
  ## 2.7.0
4
19
 
5
20
  ### Added
@@ -2,4 +2,4 @@
2
2
  * The project manager's playbook. The SessionStart hook prints it into the main session only:
3
3
  * subagents load CLAUDE.md and AGENTS.md on every turn, so anything kept there is paid for by every worker.
4
4
  */
5
- export declare const PM_PLAYBOOK = "## Working as Project Manager\n\n- Start each session with `nativ task next --json` (or the `nativ_task_next` MCP tool).\n- Delegate, do not implement. Hand each task to the `worker` agent by task id, passing the task's `recommendedModel` (haiku, sonnet or opus) as the Agent tool's `model`; with none, the worker's default applies. Ask the `verifier` agent for an independent check before you mark a milestone done. Ask the `architect` agent for anything that changes a contract.\n- Before the architect designs on existing code, run the `explorer` when `.ai/codebase_map.md` is missing or its commit is well behind `HEAD`, and pass its report to the architect. Give the explorer the current commit (`git rev-parse --short HEAD`); it cannot look it up. Pass `model: haiku` for a single lookup such as \"where is the auth middleware?\".\n- Keep usage low. Run this session on Sonnet. Agent defaults: architect Opus, worker Sonnet, verifier Haiku. Pass `haiku` for mechanical tasks (wording, docs, renames) and `sonnet` for the verifier at the end of a milestone. Never pass a model the human did not choose.\n- Independent tasks can run in parallel in worktrees (`nativ worktree create <taskId>`). On a Claude subscription every agent draws from the same usage limit, so run two or three at a time, not ten.\n- You may not edit `.ai/`. If the enforcement hook warns about a file, that is a scope or contract gap: escalate with `nativ task escalate`, do not edit around it.\n- `nativ doctor` confirms the integration is intact. Run it after updating Claude Code or nativ, and whenever hooks or MCP tools seem to be missing.\n\n## Autonomous execution\n\nWhen told to run autonomously (\"run the queue\", \"execute milestone\", AFK mode):\n1. `nativ_task_next`, then `nativ_task_start`.\n2. Delegate to the `worker` agent with the task id and recommended model.\n3. The worker completes the task itself; `nativ task complete` already ran the check. Do not run `nativ_verify` or `nativ_task_complete` again for it.\n4. Loop to the next task immediately, without asking the human.\n5. Stop only when the milestone is done (ask the `verifier` for a milestone check, then tell the human) or when a task is blocked or escalated (present the four-part summary).\n\n## Talking to the human\n\nThe human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked `(Recommended)`, then one direct question. Say \"the app tried to link a record to an item that does not exist yet\", not \"foreign key constraint violation\". Never paste stack traces; put raw output in a collapsed `<details>` block.";
5
+ export declare const PM_PLAYBOOK = "## Working as Project Manager\n\n- Start each session with `nativ task next --json` (or the `nativ_task_next` MCP tool).\n- Delegate, do not implement. Hand each task to the `worker` agent by task id, passing the task's `recommendedModel` (haiku, sonnet or opus) as the Agent tool's `model`; with none, the worker's default applies. Ask the `verifier` agent for an independent check before you mark a milestone done. Ask the `architect` agent for anything that changes a contract.\n- Before the architect designs on existing code, run the `explorer` when `.ai/codebase_map.md` is missing or its commit is well behind `HEAD`, and pass its report to the architect. Give the explorer the current commit (`git rev-parse --short HEAD`); it cannot look it up. Pass `model: haiku` for a single lookup such as \"where is the auth middleware?\".\n- Keep usage low. Run this session on Sonnet. Agent defaults: architect Opus, worker Sonnet, verifier Haiku. Pass `haiku` for mechanical tasks (wording, docs, renames) and `sonnet` for the verifier at the end of a milestone. Never pass a model the human did not choose.\n- Independent tasks can run in parallel in worktrees (`nativ worktree create <taskId>`). On a Claude subscription every agent draws from the same usage limit, so run two or three at a time, not ten.\n- You may not edit `.ai/`. If the enforcement hook warns about a file, that is a scope or contract gap: escalate with `nativ task escalate`, do not edit around it.\n- `nativ doctor` confirms the integration is intact. Run it after updating Claude Code or nativ, and whenever hooks or MCP tools seem to be missing.\n\n## Autonomous execution\n\nWhen told to run autonomously (\"run the queue\", \"execute milestone\", AFK mode):\n1. `nativ_task_next`, then `nativ_task_start`.\n2. Delegate to the `worker` agent with the task id and recommended model.\n3. The worker completes the task itself; `nativ task complete` already ran the check. Do not run `nativ_verify` or `nativ_task_complete` again for it.\n4. Loop to the next task immediately, without asking the human.\n5. Stop only when the milestone is done (ask the `verifier` for a milestone check, then tell the human) or when a task is blocked or escalated (present the four-part summary).\n\n## Talking to the human\n\nThe human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked `(Recommended)`, plus one line on when the other option would be the better choice, then one direct question. Say \"the app tried to link a record to an item that does not exist yet\", not \"foreign key constraint violation\". Never paste stack traces; put raw output in a collapsed `<details>` block.";
@@ -23,4 +23,4 @@ When told to run autonomously ("run the queue", "execute milestone", AFK mode):
23
23
 
24
24
  ## Talking to the human
25
25
 
26
- The human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked \`(Recommended)\`, then one direct question. Say "the app tried to link a record to an item that does not exist yet", not "foreign key constraint violation". Never paste stack traces; put raw output in a collapsed \`<details>\` block.`;
26
+ The human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked \`(Recommended)\`, plus one line on when the other option would be the better choice, then one direct question. Say "the app tried to link a record to an item that does not exist yet", not "foreign key constraint violation". Never paste stack traces; put raw output in a collapsed \`<details>\` block.`;
@@ -34,9 +34,9 @@ export const benchmarksScript = String.raw ` // ─── View 4: Synthetic ben
34
34
  if (env.cpuCount) meta.push(env.cpuCount + ' CPUs');
35
35
  var score = clampPct(s.score);
36
36
  return '<section class="card hero' + (running ? ' is-running' : '') + '" aria-labelledby="hero-h" aria-busy="' + running + '">' +
37
- '<div><h2 class="eyebrow" id="hero-h">Average throughput</h2>' +
37
+ '<div><h2 class="hero-title" id="hero-h">Average throughput</h2>' +
38
38
  '<div class="hero-value">' + num(Math.round(s.averageThroughputOpsPerSec || 0)) + '<span class="badge badge-accent">ops/sec</span></div>' +
39
- '<p class="hero-meta">' + meta.join(' · ') + '</p></div>' +
39
+ '<p class="hero-meta">' + meta.join(', ') + '</p></div>' +
40
40
  '<dl class="hero-stats">' +
41
41
  '<div><dt>Score</dt><dd class="' + (score < 100 ? 'text-danger' : '') + '">' + score + '%</dd></div>' +
42
42
  '<div><dt>Scenarios passed</dt><dd>' + num(s.passedScenarios) + '/' + num(s.totalScenarios) + '</dd></div>' +
@@ -48,16 +48,17 @@ export const benchmarksScript = String.raw ` // ─── View 4: Synthetic ben
48
48
  function scenarioCards(r) {
49
49
  var byId = {}, known = {};
50
50
  (r.scenarios || []).forEach(function (s) { byId[s.id] = s; });
51
- var cards = SCENARIOS.map(function (meta) { known[meta.id] = true; return scenarioCard(meta.label, byId[meta.id]); });
51
+ var cards = [], idle = [];
52
+ SCENARIOS.forEach(function (meta) {
53
+ known[meta.id] = true;
54
+ if (byId[meta.id]) cards.push(scenarioCard(meta.label, byId[meta.id])); else idle.push(meta.label);
55
+ });
52
56
  (r.scenarios || []).forEach(function (s) { if (!known[s.id]) cards.push(scenarioCard(s.name || s.id, s)); });
57
+ if (idle.length) cards.push('<p class="muted not-run">Not run: ' + idle.map(esc).join(', ') + '.</p>');
53
58
  return cards.join('');
54
59
  }
55
60
 
56
61
  function scenarioCard(label, s) {
57
- if (!s) {
58
- return '<article class="card scenario is-idle"><header class="scenario-head"><h3>' + esc(label) + '</h3><span class="badge badge-neutral">Not run</span></header>' +
59
- '<p class="muted">Not part of the last run. Run the full matrix to score it.</p></article>';
60
- }
61
62
  var total = (s.assertionsPassed || 0) + (s.assertionsFailed || 0);
62
63
  var passed = s.status === 'passed';
63
64
  var score = total ? Math.round((s.assertionsPassed || 0) / total * 100) : (passed ? 100 : 0);
@@ -76,7 +77,7 @@ export const benchmarksScript = String.raw ` // ─── View 4: Synthetic ben
76
77
  '<div class="metrics"><div><span class="metric-v">' + score + '%</span><span class="metric-l">Score</span></div>' +
77
78
  '<div><span class="metric-v">' + fmtMs(s.durationMs) + '</span><span class="metric-l">Latency</span></div>' +
78
79
  '<div><span class="metric-v">' + num(Math.round(s.throughputOpsPerSec || 0)) + '</span><span class="metric-l">ops/sec</span></div></div>' +
79
- progressBar(score, 'sc-' + s.id, label + ' score', passed ? 'success' : 'danger') +
80
+ progressBar(score, 'sc-' + s.id, label + ' score', passed ? '' : 'danger') +
80
81
  (details ? '<dl class="details">' + details + '</dl>' : '') + errors + '</article>';
81
82
  }
82
83
 
@@ -2,7 +2,9 @@
2
2
  export const benchmarksCss = String.raw `/* Benchmarks */
3
3
  .hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 20px 32px; padding: 22px 0; margin-bottom: 0; background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; box-shadow: none; transition: opacity .2s; }
4
4
  .hero.is-running { opacity: .6; }
5
- .eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
5
+ .hero-title { margin: 0; font-size: 14px; font-weight: 600; color: var(--muted); }
6
+ .not-run { margin: 0; padding: 12px 0; font-size: 13px; }
7
+ .scenario .progress { height: 4px; }
6
8
  .hero-value { display: flex; align-items: center; gap: 10px; margin: 6px 0; font-size: 40px; font-weight: 650; letter-spacing: -0.03em; line-height: 1.1; font-variant-numeric: tabular-nums; }
7
9
  .hero-value .badge { font-size: 12px; letter-spacing: 0; }
8
10
  .hero-meta { margin: 0; font-size: 13.5px; color: var(--muted); }
@@ -168,7 +168,7 @@ export const databaseBrowserScript = String.raw ` // ─── Database Studio:
168
168
  table.columns.forEach(function (col) {
169
169
  var isPk = col.primaryKey;
170
170
  var fieldLabel = esc(col.name) + (isPk ? ' (Primary Key - Optional/Auto)' : '');
171
- var placeholder = col.type + (col.default ? ' · default: ' + col.default : '');
171
+ var placeholder = col.type + (col.default ? ', default: ' + col.default : '');
172
172
  var isLong = /json|map|array|text/i.test(col.type);
173
173
  if (isLong) {
174
174
  html += '<div class="field field-full"><label><b>' + fieldLabel + '</b></label><textarea class="input" name="' + esc(col.name) + '" placeholder="' + esc(placeholder) + '"></textarea></div>';
@@ -3,10 +3,11 @@ export const databaseConnectionScript = String.raw ` // ─── Database Stud
3
3
  function renderModalStatus() {
4
4
  ['dev', 'prod'].forEach(function (env) {
5
5
  var s = state.status && state.status[env];
6
- $('mdot-' + env).className = 'dot ' + (s && s.connected ? 'on' : 'off');
6
+ var unset = !s || /No (Dev|Prod) database configured/.test(s.error || '');
7
+ $('mdot-' + env).className = 'dot ' + (s && s.connected ? 'on' : unset ? '' : 'off');
7
8
  $('current-' + env).innerHTML = s && s.maskedUrl
8
9
  ? 'Current: <code>' + esc(s.maskedUrl) + '</code><span class="copy-wrap"><span class="tooltip">Copied!</span><button class="btn small" type="button" data-copy-masked="' + env + '">Copy</button></span>'
9
- : 'Current: <span class="muted">not configured</span>';
10
+ : 'Current: <span class="muted">Not set up</span>';
10
11
  });
11
12
  }
12
13
 
@@ -139,8 +140,8 @@ export const databaseConnectionScript = String.raw ` // ─── Database Stud
139
140
  if (r.success) {
140
141
  var count = r.entityCount != null ? r.entityCount : r.tableCount;
141
142
  out.className = 'result ok';
142
- out.textContent = 'Connected to ' + (ENGINE_LABEL[r.engine] || r.engine) + ' in ' + Math.round(r.pingMs) + 'ms · ' + count + ' ' +
143
- entityNoun({ engine: r.engine }, true).toLowerCase() + ' · saved to session as ' + r.maskedUrl;
143
+ out.textContent = 'Connected to ' + (ENGINE_LABEL[r.engine] || r.engine) + ' in ' + Math.round(r.pingMs) + 'ms, ' + count + ' ' +
144
+ entityNoun({ engine: r.engine }, true).toLowerCase() + ', saved to session as ' + r.maskedUrl;
144
145
  $('url-' + env).value = '';
145
146
  $('ppass-' + env).value = '';
146
147
  return true;
@@ -53,9 +53,12 @@ export const databaseDataScript = String.raw ` // ─── View 5: Database St
53
53
  var label = env.toUpperCase();
54
54
  if (!s) return '<span class="pill"><span class="dot"></span><b>' + label + ':</b> loading…</span>';
55
55
  if (!s.connected) {
56
- var why = /No (Dev|Prod) database configured/.test(s.error || '') ? 'not configured' : 'offline';
57
- return '<span class="pill" title="' + esc(s.error || '') + '"><span class="dot off"></span><b>' + label + ':</b> ' + why +
58
- ' <span class="badge badge-offline">OFFLINE</span></span>';
56
+ if (/No (Dev|Prod) database configured/.test(s.error || '')) {
57
+ return '<span class="pill"><span class="dot"></span><b>' + label + ':</b> ' +
58
+ '<span class="badge badge-neutral">Not set up</span></span>';
59
+ }
60
+ return '<span class="pill" title="' + esc(s.error || '') + '"><span class="dot off"></span><b>' + label + ':</b> offline' +
61
+ ' <span class="badge badge-offline">Offline</span></span>';
59
62
  }
60
63
  var sourceBadge = s.synthesized && s.sourceKey
61
64
  ? ' <span class="badge badge-synth" title="Assembled in memory from fragmented .env keys">synthesized from ' + esc(keyFamily(s.sourceKey)) + '</span>'
@@ -63,7 +66,7 @@ export const databaseDataScript = String.raw ` // ─── View 5: Database St
63
66
  ? ' <span class="badge badge-source" title="Detected in ' + esc(s.exampleFile || '.env.example') + '">via ' + esc(s.sourceKey) + '</span>'
64
67
  : (s.sourceKey ? ' <span class="badge badge-source">via ' + esc(s.sourceKey) + '</span>' : '');
65
68
  return '<span class="pill" title="' + esc(s.maskedUrl) + '"><span class="dot on"></span><b>' + label + ':</b> ' +
66
- esc(ENGINE_LABEL[s.engine] || s.engine) + (s.database ? ' · ' + esc(s.database) : '') + ' (' + Math.round(s.pingMs) + 'ms): ' +
69
+ esc(ENGINE_LABEL[s.engine] || s.engine) + (s.database ? ', ' + esc(s.database) : '') + ' (' + Math.round(s.pingMs) + 'ms): ' +
67
70
  entityCount(s) + ' ' + entityNoun(s, true) + sourceBadge + ' <span class="badge badge-online">ONLINE</span></span>';
68
71
  }
69
72
 
@@ -97,15 +100,8 @@ export const databaseDataScript = String.raw ` // ─── View 5: Database St
97
100
  '<span class="diagnostic-tag">[DIAGNOSTIC]</span> ' +
98
101
  '<span class="diagnostic-msg">' + esc(suggestion) + '</span>' +
99
102
  '</div>' +
100
- '<div class="diagnostic-actions">' +
101
- '<button class="btn small" type="button" id="btn-diag-settings">Open Connection Settings</button>' +
102
- '</div>' +
103
103
  '</div>';
104
104
  bannerEl.hidden = false;
105
- var btnDiag = $('btn-diag-settings');
106
- if (btnDiag) {
107
- btnDiag.onclick = function () { openSettings(); };
108
- }
109
105
  } else {
110
106
  bannerEl.innerHTML = '';
111
107
  bannerEl.hidden = true;
@@ -158,8 +154,7 @@ export const databaseDataScript = String.raw ` // ─── View 5: Database St
158
154
  '<path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>';
159
155
 
160
156
  function emptyState(title, text) {
161
- return '<div class="empty card">' + DB_ICON + '<h3>' + esc(title) + '</h3><p>' + esc(text) + '</p>' +
162
- '<button class="btn primary" type="button" data-action="open-settings">Connection Settings</button></div>';
157
+ return '<div class="empty card">' + DB_ICON + '<h3>' + esc(title) + '</h3><p>' + esc(text) + '</p></div>';
163
158
  }
164
159
 
165
160
  `;
@@ -292,7 +292,7 @@ export const databaseDriftScript = String.raw ` // ─── Database Studio: d
292
292
  var lang = state.scriptLang;
293
293
  var unit = lang === 'json' ? ' change(s)' : ' statement(s)';
294
294
  return '<div class="sql-wrap card"><div class="sql-head"><span class="muted"><span class="lang">' + LANG_LABEL[lang] + '</span>' +
295
- (destructive ? '<span class="badge badge-dropped">DESTRUCTIVE</span> ' : '') + state.scriptCount + unit + ' · read-only preview</span>' +
295
+ (destructive ? '<span class="badge badge-dropped">DESTRUCTIVE</span> ' : '') + state.scriptCount + unit + ', read-only preview</span>' +
296
296
  '<span class="copy-wrap"><span class="tooltip">Copied!</span><button class="btn small" type="button" data-action="copy-sql">Copy ' +
297
297
  (lang === 'sql' ? 'SQL' : lang === 'js' ? 'Script' : 'JSON') + '</button></span></div>' +
298
298
  '<pre class="sql" tabindex="0" aria-label="Migration script (' + LANG_LABEL[lang] + ')"><code>' + highlightScript(state.sql, lang) + '</code></pre></div>';
@@ -123,8 +123,7 @@ export const databaseEventsScript = String.raw ` // ─── Events: Database
123
123
  return;
124
124
  }
125
125
  var action = el.getAttribute('data-action');
126
- if (action === 'open-settings') openSettings();
127
- else if (action === 'copy-sql') copyText(state.sql, el);
126
+ if (action === 'copy-sql') copyText(state.sql, el);
128
127
  else if (action === 'edit-record') openEditRecordModal(Number(el.getAttribute('data-row')));
129
128
  else if (action === 'delete-record') handleDeleteRecord(Number(el.getAttribute('data-row')));
130
129
  else if (action === 'preview-url') {
@@ -45,7 +45,7 @@ export const databaseExplorerScript = String.raw ` // ─── Database Studio
45
45
  : '';
46
46
  var docMeta = coll && (t.documentCount != null || t.sampledDocuments != null)
47
47
  ? '<p class="desc">' + (t.documentCount != null ? '~' + t.documentCount + ' documents' : 'Document count unavailable') +
48
- (t.sampledDocuments != null ? ' · fields inferred from ' + t.sampledDocuments + ' sampled' : '') + '</p>'
48
+ (t.sampledDocuments != null ? ', fields inferred from ' + t.sampledDocuments + ' sampled' : '') + '</p>'
49
49
  : '';
50
50
  return '<details class="table-card"><summary><span class="tname">' + esc(t.name) + '</span>' +
51
51
  (coll ? '<span class="badge badge-collection">COLLECTION</span>' : '') +
@@ -87,7 +87,7 @@ export const databaseExplorerScript = String.raw ` // ─── Database Studio
87
87
  var parts = [m.mimeType || 'unknown type'];
88
88
  if (m.width && m.height) parts.push(m.width + '×' + m.height);
89
89
  if (m.sizeBytes != null) parts.push(formatBytes(m.sizeBytes));
90
- return parts.join(' · ');
90
+ return parts.join(', ');
91
91
  }
92
92
 
93
93
  function showThumb(el) {
@@ -117,7 +117,7 @@ export const databaseExplorerScript = String.raw ` // ─── Database Studio
117
117
  hideThumb();
118
118
  var m = entry.media;
119
119
  $('media-title').textContent = entry.table + '.' + entry.field;
120
- $('media-sub').textContent = (m ? SOURCE_LABEL[m.source] || m.source : 'Image field') + ' · ' + entry.type;
120
+ $('media-sub').textContent = (m ? SOURCE_LABEL[m.source] || m.source : 'Image field') + ', ' + entry.type;
121
121
  $('media-thumb').innerHTML = thumbHtml(m);
122
122
  var rows = [];
123
123
  if (m) {
@@ -144,7 +144,7 @@ export const databaseExplorerScript = String.raw ` // ─── Database Studio
144
144
  var body;
145
145
  if (!s || !s.connected) {
146
146
  body = '<div class="empty" style="padding:28px 12px"><p>' + esc(s && s.error ? s.error : 'No database connected.') + '</p>' +
147
- '<button class="btn small" type="button" data-action="open-settings">Connect</button></div>';
147
+ '<p class="muted">Use Connection Settings at the top.</p></div>';
148
148
  } else if (!tables.length) {
149
149
  body = '<p class="muted">Connected, but this database has no ' + entityNoun(s, true).toLowerCase() + ' yet.</p>';
150
150
  } else if (!filtered.length) {
@@ -1,5 +1,5 @@
1
1
  // Plan layout: rounds, lanes, links and the highlight set. Pure, no DOM.
2
- export const flowLayoutScript = String.raw ` var FLOW = { W: 206, H: 58, GX: 52, GY: 10, PAD: 14, LX: 128, TOP: 30 };
2
+ export const flowLayoutScript = String.raw ` var FLOW = { W: 206, H: 74, GX: 52, GY: 10, PAD: 14, LX: 128, TOP: 30 };
3
3
 
4
4
  function flowAgentKey(t) { return t.agent || 'unassigned'; }
5
5
 
@@ -25,7 +25,7 @@ export const flowCss = String.raw `#panel-flow .seg { display: inline-flex; bord
25
25
  #panel-flow .node .l1 { display: flex; align-items: center; gap: 6px; }
26
26
  #panel-flow .node .l1 .d { margin-left: auto; font: 11px var(--mono); color: var(--fg-3); }
27
27
  #panel-flow .node .l1 .mono { font-size: 11px; }
28
- #panel-flow .node .title { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
28
+ #panel-flow .node .title { font-size: 12.5px; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
29
29
  #panel-flow .canvas.focus .node:not(.hot) { opacity: .3; }
30
30
  #panel-flow .node.sel { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
31
31
  #panel-flow .legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--fg-2); align-items: center; }
@@ -51,7 +51,7 @@ export const flowTimelineScript = String.raw ` function flowStat(k, v, s) {
51
51
  var cw = t.checkMs && t.status === 'completed' ? Math.min(w, t.checkMs * tm.sg.k) : 0;
52
52
  var lx = w > 64 ? x1 + 6 : x2 + 4;
53
53
  var took = (t.status === 'in_progress' ? 'running' : 'took ' + fmt.dur(t.durationMs)) + (t.checkMs ? ', check ' + fmt.dur(t.checkMs) : '');
54
- var tip = ui.esc(t.id + ' · ' + t.title) + '&#10;' + ui.esc(took);
54
+ var tip = ui.esc(t.id + ', ' + t.title) + '&#10;' + ui.esc(took);
55
55
  return '<g><rect class="bar-w ' + ui.esc(t.status) + '" data-task="' + ui.esc(t.id) + '" data-row="' + row + '" x="' + x1 + '" y="' + by +
56
56
  '" width="' + w + '" height="21" rx="4"><title>' + tip + '</title></rect>' +
57
57
  (cw > 1 ? '<rect class="bar-c" x="' + (x2 - cw) + '" y="' + (by + 17) + '" width="' + cw + '" height="4" rx="1"/>' : '') +
@@ -24,7 +24,7 @@ export const homeScript = String.raw ` var esc = Studio.ui.esc;
24
24
  var t = item.task;
25
25
  if (t.status === 'blocked') return t.reason || 'Paused after 3 attempts.';
26
26
  var e = item.escalation || {};
27
- return e.reason || e.summary || e.details || 'Waiting for your decision.';
27
+ return e.reason || e.summary || e.details || 'Needs your decision.';
28
28
  }
29
29
 
30
30
  function needActions(item) {
@@ -55,16 +55,8 @@ export const homeScript = String.raw ` var esc = Studio.ui.esc;
55
55
  rightText(count) + '</div>' + n.map(needItem).join('') + '</section>';
56
56
  }
57
57
 
58
- function isReady(model, t) {
59
- if (t.status !== 'pending') return false;
60
- return t.isAvailable || t.deps.every(function (d) {
61
- var dep = model.byId[d];
62
- return !dep || dep.status === 'completed';
63
- });
64
- }
65
-
66
58
  function readyTasks(model) {
67
- return model.tasks.filter(function (t) { return isReady(model, t); });
59
+ return model.tasks.filter(Studio.status.isReady);
68
60
  }
69
61
 
70
62
  function dispatchBtn(id, label, cls) {
@@ -75,17 +67,18 @@ export const homeScript = String.raw ` var esc = Studio.ui.esc;
75
67
  function taskCells(t) {
76
68
  return '<span class="mono id">' + esc(t.id) + '</span>' +
77
69
  '<button type="button" class="t link" data-task="' + esc(t.id) + '">' + esc(t.title) + '</button>' +
78
- '<span class="agent">' + esc(t.agent || '') + '</span>';
70
+ '<span class="agent">' + esc(Studio.agentInfo(t.agent).label) + '</span>';
79
71
  }
80
72
 
81
73
  function runRow(t) {
82
74
  var since = t.startT ? ' data-since="' + t.startT + '">' + Studio.fmt.dur(Studio.now() - t.startT) : '>';
83
75
  var row = '<div class="row run-row">' + taskCells(t) +
84
- '<span class="state"><i class="dot run"></i>Running</span>' +
76
+ '<span class="state">Running</span>' +
85
77
  '<span class="mono num timer"' + since + '</span></div>';
86
78
  var log = '<span class="mono log">' + esc(t.latestLog || '') + '</span>';
87
- return row + '<div class="row sub-row">' + log + '<button type="button" class="btn" data-act="home-console"' +
88
- ' data-id="' + esc(t.id) + '">Open console</button></div>';
79
+ var watch = t.run ? '<button type="button" class="btn" data-act="home-console" data-id="' +
80
+ esc(t.id) + '">Watch live</button>' : '';
81
+ return row + '<div class="row sub-row">' + log + watch + '</div>';
89
82
  }
90
83
 
91
84
  function nowRunning(model) {
@@ -96,23 +89,18 @@ export const homeScript = String.raw ` var esc = Studio.ui.esc;
96
89
  return section('running', 'Now running', '', '<div class="row calm"><span>No agent is running.</span>' + btn + '</div>');
97
90
  }
98
91
 
99
- function waitOn(model, t) {
100
- var open = t.deps.filter(function (d) { var x = model.byId[d]; return x && x.status !== 'completed'; });
101
- return open[0] || '';
102
- }
103
-
104
- function nextRow(model, t) {
105
- var end = isReady(model, t) ? dispatchBtn(t.id, 'Dispatch') :
106
- '<span class="wait">waits <span class="mono">' + esc(waitOn(model, t)) + '</span></span>';
92
+ function nextRow(t) {
93
+ var end = Studio.status.isReady(t) ? dispatchBtn(t.id, 'Dispatch') :
94
+ '<span class="wait">' + esc(Studio.status.waitText(t)) + '</span>';
107
95
  return '<div class="row next-row">' + taskCells(t) + end + '</div>';
108
96
  }
109
97
 
110
98
  function upNext(model) {
111
99
  var list = model.tasks.filter(function (t) { return t.status === 'pending'; });
112
- var ready = list.filter(function (t) { return isReady(model, t); });
113
- var waiting = list.filter(function (t) { return !isReady(model, t); });
100
+ var ready = list.filter(Studio.status.isReady);
101
+ var waiting = list.filter(function (t) { return !Studio.status.isReady(t); });
114
102
  var rows = ready.concat(waiting).slice(0, UP_NEXT_CAP);
115
- var body = rows.length ? rows.map(function (t) { return nextRow(model, t); }).join('') :
103
+ var body = rows.length ? rows.map(nextRow).join('') :
116
104
  '<div class="row calm">Nothing is waiting.</div>';
117
105
  return section('next', 'Up next', '', body);
118
106
  }
@@ -127,18 +115,27 @@ export const homeScript = String.raw ` var esc = Studio.ui.esc;
127
115
  return section('milestone', 'Milestone', '', body);
128
116
  }
129
117
 
118
+ // One event per task that has started or finished; the state word comes from its status now.
130
119
  function activityEvents(model) {
131
120
  var out = [];
132
121
  model.tasks.forEach(function (t) {
133
- if (t.endT) out.push({ at: t.endT, id: t.id, what: 'completed' });
134
- if (t.startT) out.push({ at: t.startT, id: t.id, what: 'started' });
122
+ var at = t.status === 'completed' ? (t.endT || t.startT) : t.startT;
123
+ if (at) out.push({ at: at, task: t });
135
124
  });
136
125
  return out.sort(function (a, b) { return b.at - a.at; }).slice(0, ACTIVITY_CAP);
137
126
  }
138
127
 
128
+ function activityState(t) {
129
+ var word = Studio.status.word(Studio.status.bucket(t));
130
+ var took = t.status === 'completed' && t.durationMs ? ', took ' + Studio.fmt.dur(t.durationMs) : '';
131
+ return word + took;
132
+ }
133
+
139
134
  function activityRow(e) {
140
- return '<div class="row act-row" data-event="' + esc(e.id) + '"><span class="mono num when">' +
141
- Studio.ui.when(e.at) + '</span><span class="mono id">' + esc(e.id) + '</span><span>' + e.what + '</span></div>';
135
+ var t = e.task;
136
+ return '<div class="row act-row" data-event="' + esc(t.id) + '"><span class="mono num when">' +
137
+ Studio.ui.when(e.at) + '</span><button type="button" class="t link" data-task="' + esc(t.id) + '">' +
138
+ esc(t.title) + '</button><span class="state">' + esc(activityState(t)) + '</span></div>';
142
139
  }
143
140
 
144
141
  function recentActivity(model) {
@@ -1,20 +1,17 @@
1
1
  // Home page styles, all scoped under #panel-home. Ink: flat sections, one raised decision surface.
2
- export const homeCss = String.raw `#panel-home .home { max-width: 960px; display: flex; flex-direction: column; }
2
+ export const homeCss = String.raw `#panel-home .home { display: flex; flex-direction: column; }
3
3
  #panel-home .page-head h1 { font-size: 22px; font-weight: 600; margin: 0 0 12px; letter-spacing: -.01em; }
4
4
  #panel-home .home-sec { border-top: 1px solid var(--line); padding: 12px 0; min-width: 0; }
5
5
  #panel-home .sec-head { display: flex; align-items: center; gap: 8px; min-height: 28px; }
6
6
  #panel-home .sec-head h2 { font-size: 13px; font-weight: 600; margin: 0; color: var(--fg-2); }
7
7
  #panel-home .sec-head .right { margin-left: auto; font-size: 12px; color: var(--fg-3); }
8
8
  #panel-home .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
9
- #panel-home .row { display: flex; align-items: center; gap: 12px; min-height: 36px; border-bottom: 1px solid var(--line); }
10
- #panel-home .row:last-child { border-bottom: 0; }
9
+ #panel-home .row { display: flex; align-items: center; gap: 12px; min-height: 36px; }
11
10
  #panel-home .row .id { width: 72px; flex: none; color: var(--fg-2); font-size: 12px; }
12
11
  #panel-home .row .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
13
12
  #panel-home .link { background: none; border: 0; padding: 0; text-align: left; color: var(--fg); cursor: pointer; font: inherit; }
14
13
  #panel-home .agent { width: 96px; flex: none; color: var(--fg-2); font-size: 12.5px; }
15
14
  #panel-home .state { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
16
- #panel-home .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fg-3); }
17
- #panel-home .dot.run { background: var(--run); }
18
15
  #panel-home .timer { width: 64px; text-align: right; font-size: 12px; }
19
16
  #panel-home .sub-row .log { flex: 1; min-width: 0; font-size: 12px; color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
20
17
  #panel-home .wait { color: var(--fg-3); font-size: 12.5px; }
@@ -30,7 +27,6 @@ export const homeCss = String.raw `#panel-home .home { max-width: 960px; display
30
27
  #panel-home .act-row .when { width: 72px; flex: none; font-size: 12px; color: var(--fg-3); }
31
28
  #panel-home .skel-row .skel { height: 12px; width: 100%; border-radius: 6px; background: var(--surface-2); }
32
29
  @media (max-width: 1024px) {
33
- #panel-home .home { max-width: none; }
34
30
  #panel-home .agent { display: none; }
35
31
  }
36
32
  `;
@@ -3,11 +3,7 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
3
3
  var CAP = 5;
4
4
  var view = { lastMs: null, showAll: false, open: {}, mode: null, filter: 'all', groups: {} };
5
5
  var KEY = 'nativ-studio:tasks-view';
6
- var FILTERS = [['all', 'All'], ['running', 'Running'], ['ready', 'Ready'], ['blocked', 'Blocked'],
7
- ['done', 'Done']];
8
- var DOT = { completed: 'ok', in_progress: 'run', blocked: 'bad', ready: 'fg', waiting: 'faint' };
9
- var LABEL = { completed: 'Done', in_progress: 'Running', blocked: 'Blocked', ready: 'Ready',
10
- waiting: 'Waiting' };
6
+ var FILTERS = [['all', 'All']].concat(Studio.status.buckets);
11
7
 
12
8
  function readMode() {
13
9
  try { return localStorage.getItem(KEY) === 'board' ? 'board' : 'list'; } catch (e) { return 'list'; }
@@ -16,30 +12,15 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
16
12
  try { localStorage.setItem(KEY, mode); } catch (e) { /* private mode */ }
17
13
  }
18
14
 
19
- // Same notion of ready as Home and the sidebar: pending and every dependency done.
20
- function isReady(model, t) {
21
- if (t.status !== 'pending') return false;
22
- return t.isAvailable || t.deps.every(function (d) {
23
- var dep = model.byId && model.byId[d];
24
- return !dep || dep.status === 'completed';
25
- });
26
- }
27
- function kindOf(model, t) {
28
- if (t.status === 'pending') return isReady(model, t) ? 'ready' : 'waiting';
29
- return t.status;
30
- }
31
15
  function matches(model, t, f) {
32
- if (f === 'all') return true;
33
- if (f === 'running') return t.status === 'in_progress';
34
- if (f === 'blocked') return t.status === 'blocked';
35
- if (f === 'done') return t.status === 'completed';
36
- return isReady(model, t);
16
+ return f === 'all' || Studio.status.bucket(t) === f;
37
17
  }
38
18
 
39
19
  function toolbar(model) {
40
20
  var chips = FILTERS.map(function (f) {
41
21
  var on = view.filter === f[0];
42
22
  var n = model.tasks.filter(function (t) { return matches(model, t, f[0]); }).length;
23
+ if (!n && !on && f[0] !== 'all') return '';
43
24
  return '<button type="button" class="tf' + (on ? ' on' : '') + '" data-act="tasks-filter" data-filter="' +
44
25
  f[0] + '" aria-pressed="' + on + '">' + f[1] + ' <span class="num">' + n + '</span></button>';
45
26
  }).join('');
@@ -59,15 +40,17 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
59
40
 
60
41
  // Rows use data-tid, not data-task, so the nested Dispatch button gets its own click.
61
42
  function listRow(model, t, fmt) {
62
- var k = kindOf(model, t);
43
+ var bucket = Studio.status.bucket(t);
44
+ var wait = Studio.status.waitText(t);
63
45
  var id = ui.esc(t.id);
64
- var go = k === 'ready' ? '<button type="button" class="tdispatch" data-act="tasks-dispatch" data-tid="' +
46
+ var go = Studio.status.isReady(t) ? '<button type="button" class="tdispatch" data-act="tasks-dispatch" data-tid="' +
65
47
  id + '">Dispatch</button>' : '';
66
48
  return '<div class="trow" role="row" tabindex="0" data-act="tasks-open" data-tid="' + id + '">' +
67
49
  '<span class="c-id mono" role="cell">' + id + '</span>' +
68
50
  '<span class="c-title" role="cell" title="' + ui.esc(t.title) + '">' + ui.esc(t.title) + '</span>' +
69
- '<span class="c-agent" role="cell">' + ui.esc(t.agent || '') + '</span>' +
70
- '<span class="c-status st-' + DOT[k] + '" role="cell"><i class="dot"></i>' + LABEL[k] + go + '</span>' +
51
+ '<span class="c-agent" role="cell">' + ui.esc(Studio.agentInfo(t.agent).label) + '</span>' +
52
+ '<span class="c-status st-' + bucket + '" role="cell">' + Studio.status.word(bucket) +
53
+ (wait ? ' <span class="faint">' + ui.esc(wait) + '</span>' : '') + go + '</span>' +
71
54
  '<span class="c-time mono" role="cell">' + ui.esc(timeOf(t, fmt)) + '</span></div>';
72
55
  }
73
56
 
@@ -86,7 +69,7 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
86
69
  return '<section class="tgroup" role="table" aria-label="' + ui.esc(m.name) + '">' +
87
70
  '<button type="button" class="tg-head" data-act="tasks-group" data-group="' + ui.esc(m.id) +
88
71
  '" aria-expanded="' + open + '"><span class="chev">' + ui.icon('chev', 2) + '</span>' +
89
- '<span class="mono faint">' + ui.esc(m.id) + '</span><span class="t">' + ui.esc(m.name) + '</span>' +
72
+ '<span class="mono faint">' + ui.esc(m.id) + '</span><span class="t" title="' + ui.esc(m.name) + '">' + ui.esc(m.name) + '</span>' +
90
73
  '<span class="num faint">' + m.done + ' of ' + m.total + '</span></button>' + body + '</section>';
91
74
  }
92
75
 
@@ -124,13 +107,13 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
124
107
  var el = e.target;
125
108
  if (e.key !== 'Enter' || !el || !el.matches || !el.matches('.trow[data-tid]')) return;
126
109
  e.preventDefault();
127
- Studio.taskAction('logs', el.getAttribute('data-tid'));
110
+ Studio.openTask(el.getAttribute('data-tid'));
128
111
  });
129
112
  }
130
113
 
131
114
  function head(m) {
132
115
  var lead = m ? ui.esc(m.name) : 'The work of one milestone, by state.';
133
- return '<div class="panel-head"><div class="panel-title"><h1>Tasks</h1><p class="lead">' + lead + '</p></div>' +
116
+ return '<div class="panel-head"><div class="panel-title"><h1>Tasks</h1><p class="lead" title="' + lead + '">' + lead + '</p></div>' +
134
117
  '<div class="panel-actions">' + ui.milestoneSelect() + '</div></div>';
135
118
  }
136
119
 
@@ -138,7 +121,8 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
138
121
 
139
122
  function metaHtml(t, fmt) {
140
123
  if (t.status === 'completed') {
141
- return '<span>' + ui.esc(fmt.ago(t.endT)) + '</span>' + (t.durationMs ? '<span>took ' + ui.esc(fmt.dur(t.durationMs)) + '</span>' : '');
124
+ var took = t.durationMs ? '<span>took ' + ui.esc(fmt.dur(t.durationMs)) + '</span>' : '';
125
+ return '<span>' + ui.esc(fmt.ago(t.endT)) + '</span>' + took;
142
126
  }
143
127
  if (t.status === 'in_progress') {
144
128
  var live = t.startT ? '<span data-since="' + t.startT + '">' + fmt.dur(Studio.now() - t.startT) + '</span>' : '';
@@ -147,7 +131,7 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
147
131
  if (t.status === 'blocked') {
148
132
  return '<span class="bad">Paused after ' + (t.attempt || t.maxAttempts) + ' of ' + t.maxAttempts + ' attempts</span>';
149
133
  }
150
- if (t.waitingOn.length) return '<span>Waiting on ' + t.waitingOn.map(ui.esc).join(', ') + '</span>';
134
+ if (t.waitingOn.length && !t.stale) return '<span>Waits for ' + t.waitingOn.map(ui.esc).join(', ') + '</span>';
151
135
  return '<span>Ready to start</span>';
152
136
  }
153
137
 
@@ -157,8 +141,9 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
157
141
  '<div class="title">' + ui.esc(t.title) + '</div><div class="meta">' + metaHtml(t, fmt) + '</div></button>';
158
142
  }
159
143
 
160
- function column(label, status, list, shown, empty, extra, fmt) {
161
- var body = shown.length ? shown.map(function (t) { return card(t, fmt); }).join('') : '<div class="col-empty">' + empty + '</div>';
144
+ function column(label, status, list, shown, extra, fmt) {
145
+ var body = shown.length ? shown.map(function (t) { return card(t, fmt); }).join('')
146
+ : '<p class="col-empty">None</p>';
162
147
  return '<div class="col" data-col="' + status + '"><div class="col-head">' + ui.statusIcon(status) + label +
163
148
  '<span class="count">' + list.length + '</span></div>' + body + (extra || '') + '</div>';
164
149
  }
@@ -169,12 +154,12 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
169
154
  var shown = view.showAll ? done : done.slice(0, CAP);
170
155
  var more = done.length > CAP
171
156
  ? '<button type="button" class="more" data-act="tasks-more">' + (view.showAll ? 'Show fewer' : 'Show all ' + done.length) + '</button>' : '';
172
- var up = by('pending'), run = by('in_progress'), stuck = by('blocked');
173
- return '<div class="board">' +
174
- column('Up next', 'pending', up, up, 'Nothing queued', '', fmt) +
175
- column('Running', 'in_progress', run, run, 'No task is running', '', fmt) +
176
- column('Just finished', 'completed', done, shown, 'Nothing finished yet', more, fmt) +
177
- column('Stuck', 'blocked', stuck, stuck, 'Nothing is stuck', '', fmt) + '</div>';
157
+ var lanes = [['Up next', 'pending', by('pending'), null], ['Running', 'in_progress', by('in_progress'), null],
158
+ ['Done', 'completed', done, shown], ['Stuck', 'blocked', by('blocked'), null]];
159
+ var cols = lanes.map(function (l) {
160
+ return column(l[0], l[1], l[2], l[3] || l[2], l[1] === 'completed' ? more : '', fmt);
161
+ }).join('');
162
+ return '<div class="board">' + cols + '</div>';
178
163
  }
179
164
 
180
165
  function histRow(m, fmt) {
@@ -224,7 +209,7 @@ export const tasksScript = String.raw ` var ui = Studio.ui;
224
209
  },
225
210
  'tasks-filter': function (el) { view.filter = el.getAttribute('data-filter'); Studio.repaint('tasks'); },
226
211
  'tasks-clear': function () { view.filter = 'all'; Studio.repaint('tasks'); },
227
- 'tasks-open': function (el) { Studio.taskAction('logs', el.getAttribute('data-tid')); },
212
+ 'tasks-open': function (el) { Studio.openTask(el.getAttribute('data-tid')); },
228
213
  'tasks-dispatch': function (el) { Studio.taskAction('dispatch', el.getAttribute('data-tid')); },
229
214
  'tasks-group': function (el, ctx) {
230
215
  var id = el.getAttribute('data-group');