codehere 0.6.1 → 0.7.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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +84 -5
- package/dist/application/use-cases/generate-narration.js +20 -3
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +218 -19
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +5 -5
- package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
- package/dist/infrastructure/orchestration/context-handoff.js +4 -3
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
- package/dist/infrastructure/orchestration/graphify-context.js +6 -13
- package/dist/infrastructure/orchestration/memory-injector.js +11 -16
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
- package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
- package/dist/infrastructure/orchestration/session-manager.js +142 -29
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +10 -11
- package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
- package/dist/infrastructure/storage/project-thread-migration.js +14 -9
- package/dist/infrastructure/storage/project-thread-reader.js +11 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/init-command.js +5 -4
- package/dist/presentation/cli/commands/memory-command.js +29 -15
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +52 -2
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +54 -29
- package/dist/utils/typo-guard.js +1 -1
- package/package.json +4 -2
|
@@ -1380,10 +1380,17 @@ ${AGENT_BADGE_JS}
|
|
|
1380
1380
|
if (entry.matched) return String(entry.matched);
|
|
1381
1381
|
|
|
1382
1382
|
const raw = entry.toolInput;
|
|
1383
|
-
if (!raw) return
|
|
1383
|
+
if (!raw) return '';
|
|
1384
1384
|
|
|
1385
1385
|
try {
|
|
1386
1386
|
const input = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1387
|
+
// W-PASS audit entries (v0.9 S6) use target/method/reason/passId fields.
|
|
1388
|
+
// Without this branch summarize() falls back to entry.toolName and the
|
|
1389
|
+
// row template renders "WritePass.intent WritePass.intent" twice.
|
|
1390
|
+
if (input.target && input.method) {
|
|
1391
|
+
const reason = input.reason ? ' · ' + String(input.reason).slice(0, 80) : '';
|
|
1392
|
+
return String(input.method) + ' ' + String(input.target) + reason;
|
|
1393
|
+
}
|
|
1387
1394
|
if (input.command) return String(input.command).replace(/\\n/g, ' ').slice(0, 140);
|
|
1388
1395
|
if (input.file_path) return shortPath(String(input.file_path));
|
|
1389
1396
|
if (Array.isArray(input.edits) && input.edits[0] && input.edits[0].file_path) {
|
|
@@ -1401,7 +1408,9 @@ ${AGENT_BADGE_JS}
|
|
|
1401
1408
|
const cmd = s.match(/"command"\\s*:\\s*"([^"]{0,140})"/);
|
|
1402
1409
|
if (cmd) return cmd[1].replace(/\\\\n/g, ' ');
|
|
1403
1410
|
|
|
1404
|
-
|
|
1411
|
+
// No meaningful summary derivable. Return empty so the row template
|
|
1412
|
+
// can hide the summary span instead of duplicating the tool name.
|
|
1413
|
+
return '';
|
|
1405
1414
|
}
|
|
1406
1415
|
|
|
1407
1416
|
function escape(s) {
|
|
@@ -1455,7 +1464,7 @@ ${AGENT_BADGE_JS}
|
|
|
1455
1464
|
(originAgent ? agentBadgeHTML(originAgent) : '') +
|
|
1456
1465
|
'<span class="outcome ' + outcome + '">' + outcome + '</span>' +
|
|
1457
1466
|
'<span class="tool">' + escape(tool) + '</span>' +
|
|
1458
|
-
'<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' +
|
|
1467
|
+
(summary ? '<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' : '') +
|
|
1459
1468
|
'<span class="score ' + (score ? '' : 'zero') + '">' + (score ? score : '—') + '</span>';
|
|
1460
1469
|
feed.insertBefore(li, feed.firstChild);
|
|
1461
1470
|
applyFilterToRow(li);
|
|
@@ -1697,3302 +1706,6 @@ ${AGENT_BADGE_JS}
|
|
|
1697
1706
|
</script>
|
|
1698
1707
|
</body>
|
|
1699
1708
|
</html>`;
|
|
1700
|
-
// ── / — task intake + active session view (v0.3 F1) ───────────────────────────
|
|
1701
|
-
export const TASK_HTML = `<!doctype html>
|
|
1702
|
-
<html lang="en">
|
|
1703
|
-
<head>
|
|
1704
|
-
<meta charset="utf-8" />
|
|
1705
|
-
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1706
|
-
<title>codehere</title>
|
|
1707
|
-
<style>
|
|
1708
|
-
${SHARED_STYLES}
|
|
1709
|
-
* { box-sizing: border-box; }
|
|
1710
|
-
body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
|
|
1711
|
-
|
|
1712
|
-
header {
|
|
1713
|
-
position: sticky; top: 0; z-index: 10;
|
|
1714
|
-
background: rgba(0,0,0,0.75); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
|
1715
|
-
border-bottom: 1px solid var(--border);
|
|
1716
|
-
padding: 14px 24px;
|
|
1717
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
1718
|
-
}
|
|
1719
|
-
header .brand { display: flex; align-items: center; gap: 14px; }
|
|
1720
|
-
header .title { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
1721
|
-
header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
|
|
1722
|
-
header nav { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; }
|
|
1723
|
-
header nav a { color: var(--fg-50); text-decoration: none; padding: 4px 8px; border-radius: 4px; }
|
|
1724
|
-
header nav a:hover { color: var(--fg); background: rgba(255,255,255,0.04); }
|
|
1725
|
-
header nav a.active { color: var(--fg); background: rgba(255,255,255,0.06); }
|
|
1726
|
-
|
|
1727
|
-
.shell { max-width: 960px; margin: 0 auto; padding: 90px 24px 24px; position: relative; }
|
|
1728
|
-
|
|
1729
|
-
/* ── Apr 29 — Slack-shape 3-pane layout ──────────────────────────────────
|
|
1730
|
-
The app surface restructures from single-column dashboard to
|
|
1731
|
-
channels (left) + threads (center) + members (right). Single
|
|
1732
|
-
channel = current cwd; members = available agents from readiness API.
|
|
1733
|
-
Multi-channel + live presence dots from F1d detection are a later release. */
|
|
1734
|
-
.shell.slack-shape {
|
|
1735
|
-
max-width: 1440px;
|
|
1736
|
-
display: grid;
|
|
1737
|
-
grid-template-columns: 200px 1fr 220px;
|
|
1738
|
-
gap: 16px;
|
|
1739
|
-
align-items: start;
|
|
1740
|
-
}
|
|
1741
|
-
@media (max-width: 1100px) {
|
|
1742
|
-
.shell.slack-shape { grid-template-columns: 1fr; }
|
|
1743
|
-
.shell.slack-shape .channels-pane, .shell.slack-shape .members-pane {
|
|
1744
|
-
position: static; order: 99;
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
.channels-pane, .members-pane {
|
|
1748
|
-
background: rgba(255,255,255,0.025);
|
|
1749
|
-
border: 1px solid var(--border-soft);
|
|
1750
|
-
border-radius: 10px;
|
|
1751
|
-
padding: 14px 12px;
|
|
1752
|
-
position: sticky; top: 88px;
|
|
1753
|
-
font-family: var(--mono);
|
|
1754
|
-
z-index: 1;
|
|
1755
|
-
}
|
|
1756
|
-
.pane-head {
|
|
1757
|
-
font-size: 9.5px; letter-spacing: 0.16em;
|
|
1758
|
-
color: var(--fg-30); margin-bottom: 10px;
|
|
1759
|
-
text-transform: uppercase;
|
|
1760
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
1761
|
-
}
|
|
1762
|
-
.pane-head .pane-count { color: var(--fg-50); }
|
|
1763
|
-
.pane-add {
|
|
1764
|
-
background: transparent; border: 1px dashed var(--border);
|
|
1765
|
-
color: var(--fg-50); font-family: var(--mono); font-size: 10px;
|
|
1766
|
-
padding: 4px 8px; border-radius: 6px; cursor: pointer;
|
|
1767
|
-
width: 100%; margin-top: 8px;
|
|
1768
|
-
}
|
|
1769
|
-
.pane-add:hover { color: var(--fg); border-color: var(--fg-30); border-style: solid; }
|
|
1770
|
-
.channel, .member {
|
|
1771
|
-
display: flex; align-items: center; gap: 8px;
|
|
1772
|
-
padding: 6px 8px; border-radius: 6px;
|
|
1773
|
-
font-size: 11.5px;
|
|
1774
|
-
cursor: pointer;
|
|
1775
|
-
color: var(--fg-70);
|
|
1776
|
-
border: 1px solid transparent;
|
|
1777
|
-
}
|
|
1778
|
-
.channel:hover, .member:hover { background: rgba(255,255,255,0.04); color: var(--fg); }
|
|
1779
|
-
.channel.active { background: rgba(255,154,118,0.08); color: var(--fg); border-color: rgba(255,154,118,0.25); }
|
|
1780
|
-
.channel-glyph, .member-presence {
|
|
1781
|
-
color: var(--fg-40); flex: 0 0 14px; text-align: center;
|
|
1782
|
-
}
|
|
1783
|
-
.member-presence.ready { color: var(--allow); }
|
|
1784
|
-
.member-presence.warn { color: var(--warn); }
|
|
1785
|
-
.member-presence.offline { color: var(--fg-30); }
|
|
1786
|
-
.channel-name, .member-label {
|
|
1787
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1788
|
-
flex: 1; min-width: 0;
|
|
1789
|
-
}
|
|
1790
|
-
.member-cwd-hint, .channel-cwd-hint {
|
|
1791
|
-
font-size: 9.5px; color: var(--fg-30);
|
|
1792
|
-
margin-top: 4px; padding-left: 22px;
|
|
1793
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1794
|
-
}
|
|
1795
|
-
.pane-empty {
|
|
1796
|
-
color: var(--fg-30); font-size: 10.5px;
|
|
1797
|
-
padding: 8px; text-align: center;
|
|
1798
|
-
}
|
|
1799
|
-
/* Pencil rename button — was missing from TASK_HTML's style block, so the
|
|
1800
|
-
SVG rendered without sizing constraints and showed as a white square on
|
|
1801
|
-
the active project row. Same rules as THREAD_HTML's copy. */
|
|
1802
|
-
.channel-rename {
|
|
1803
|
-
background: transparent; border: none; cursor: pointer;
|
|
1804
|
-
color: var(--fg-30); padding: 4px;
|
|
1805
|
-
transition: color 0.12s ease;
|
|
1806
|
-
flex: 0 0 auto;
|
|
1807
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
1808
|
-
outline: none;
|
|
1809
|
-
}
|
|
1810
|
-
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
1811
|
-
.channel-rename:hover, .channel-rename:focus { color: var(--fg); background: transparent; }
|
|
1812
|
-
.channel-rename:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
|
1813
|
-
|
|
1814
|
-
/* ── Readiness banner (F1a) ─────────────────────────────────────────── */
|
|
1815
|
-
.readiness {
|
|
1816
|
-
margin: 12px 0 0;
|
|
1817
|
-
padding: 14px 16px;
|
|
1818
|
-
border-radius: 10px;
|
|
1819
|
-
border: 1px solid var(--border);
|
|
1820
|
-
background: var(--bg-1);
|
|
1821
|
-
display: none;
|
|
1822
|
-
}
|
|
1823
|
-
.readiness[data-severity="warn"] {
|
|
1824
|
-
border-color: rgba(254,188,46,0.35);
|
|
1825
|
-
background: rgba(254,188,46,0.05);
|
|
1826
|
-
}
|
|
1827
|
-
.readiness[data-severity="blocker"] {
|
|
1828
|
-
border-color: rgba(255,95,87,0.4);
|
|
1829
|
-
background: rgba(255,95,87,0.06);
|
|
1830
|
-
}
|
|
1831
|
-
.readiness .r-title {
|
|
1832
|
-
font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--fg);
|
|
1833
|
-
margin-bottom: 4px;
|
|
1834
|
-
}
|
|
1835
|
-
.readiness[data-severity="warn"] .r-title { color: var(--warn); }
|
|
1836
|
-
.readiness[data-severity="blocker"] .r-title { color: var(--block); }
|
|
1837
|
-
.readiness .r-detail { font-family: var(--mono); font-size: 11px; color: var(--fg-70); line-height: 1.5; }
|
|
1838
|
-
.readiness .r-action { margin-top: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
1839
|
-
.readiness .r-action code { background: rgba(255,255,255,0.06); padding: 3px 8px; border-radius: 4px; font-family: var(--mono); font-size: 11px; }
|
|
1840
|
-
.readiness .r-action a { color: var(--accent); font-family: var(--mono); font-size: 11px; text-decoration: none; }
|
|
1841
|
-
.readiness .r-action a:hover { text-decoration: underline; }
|
|
1842
|
-
.readiness .r-action button {
|
|
1843
|
-
background: transparent; color: var(--fg-70); font-family: var(--mono); font-size: 11px;
|
|
1844
|
-
border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer;
|
|
1845
|
-
}
|
|
1846
|
-
.readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1847
|
-
|
|
1848
|
-
/* v0.6.x P0 — failover offer banner. Amber stripe: "Agent A rate-limited,
|
|
1849
|
-
switch to Agent B?" with one-click dismiss + accept buttons. */
|
|
1850
|
-
.failover-banner {
|
|
1851
|
-
margin: 14px 0 0; padding: 14px 20px;
|
|
1852
|
-
border: 1px solid rgba(254,188,46,0.3);
|
|
1853
|
-
background: rgba(254,188,46,0.06);
|
|
1854
|
-
border-radius: 10px;
|
|
1855
|
-
font-size: 13px; line-height: 1.5;
|
|
1856
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
1857
|
-
gap: 12px; flex-wrap: wrap;
|
|
1858
|
-
}
|
|
1859
|
-
.failover-banner .fb-msg { flex: 1; color: var(--fg-70); min-width: 200px; }
|
|
1860
|
-
.failover-banner .fb-msg b { color: var(--fg); font-weight: 600; }
|
|
1861
|
-
.failover-banner .fb-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
|
1862
|
-
.failover-banner .fb-actions button {
|
|
1863
|
-
font-family: var(--mono); font-size: 11px;
|
|
1864
|
-
padding: 5px 14px; border-radius: 6px; cursor: pointer;
|
|
1865
|
-
border: 1px solid var(--border); background: transparent;
|
|
1866
|
-
color: var(--fg-70); transition: color 0.15s, border-color 0.15s;
|
|
1867
|
-
}
|
|
1868
|
-
.failover-banner .fb-actions button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1869
|
-
.failover-banner .fb-actions button.fb-accept {
|
|
1870
|
-
background: rgba(254,188,46,0.12); border-color: rgba(254,188,46,0.4);
|
|
1871
|
-
color: var(--warn); font-weight: 600;
|
|
1872
|
-
}
|
|
1873
|
-
.failover-banner .fb-actions button.fb-accept:hover { background: rgba(254,188,46,0.2); }
|
|
1874
|
-
|
|
1875
|
-
/* v0.6 S2 — first-time onboarding card. Quiet authority: peach
|
|
1876
|
-
accent border + subtle glass, doesn't compete with the textarea
|
|
1877
|
-
below. Removed once dismissed OR once any session exists. */
|
|
1878
|
-
.onboarding-card {
|
|
1879
|
-
margin: 30px 0 18px; padding: 24px 28px;
|
|
1880
|
-
border: 1px solid rgba(255,154,118,0.25);
|
|
1881
|
-
background: linear-gradient(135deg, rgba(255,154,118,0.04) 0%, rgba(74,144,226,0.03) 100%);
|
|
1882
|
-
border-radius: 12px;
|
|
1883
|
-
position: relative; z-index: 1;
|
|
1884
|
-
}
|
|
1885
|
-
.onboarding-card .ob-head {
|
|
1886
|
-
display: flex; align-items: flex-start; justify-content: space-between;
|
|
1887
|
-
margin-bottom: 6px;
|
|
1888
|
-
}
|
|
1889
|
-
.onboarding-card .ob-title {
|
|
1890
|
-
font-family: var(--sans); font-size: 18px; font-weight: 700;
|
|
1891
|
-
color: var(--fg);
|
|
1892
|
-
background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
|
|
1893
|
-
-webkit-background-clip: text; background-clip: text;
|
|
1894
|
-
-webkit-text-fill-color: transparent;
|
|
1895
|
-
}
|
|
1896
|
-
.onboarding-card .ob-close {
|
|
1897
|
-
background: transparent; border: none; cursor: pointer;
|
|
1898
|
-
color: var(--fg-40); font-size: 22px; line-height: 1;
|
|
1899
|
-
padding: 0 4px;
|
|
1900
|
-
transition: color 0.15s ease;
|
|
1901
|
-
}
|
|
1902
|
-
.onboarding-card .ob-close:hover { color: var(--fg); }
|
|
1903
|
-
.onboarding-card .ob-blurb {
|
|
1904
|
-
font-family: var(--sans); font-size: 13px; line-height: 1.55;
|
|
1905
|
-
color: var(--fg-70); margin-bottom: 16px; max-width: 640px;
|
|
1906
|
-
}
|
|
1907
|
-
.onboarding-card .ob-steps {
|
|
1908
|
-
list-style: none; padding: 0; margin: 0;
|
|
1909
|
-
display: flex; flex-direction: column; gap: 10px;
|
|
1910
|
-
}
|
|
1911
|
-
.onboarding-card .ob-steps li {
|
|
1912
|
-
display: flex; gap: 12px; align-items: flex-start;
|
|
1913
|
-
}
|
|
1914
|
-
.onboarding-card .ob-num {
|
|
1915
|
-
flex: 0 0 22px; height: 22px; line-height: 22px;
|
|
1916
|
-
border-radius: 50%; text-align: center;
|
|
1917
|
-
background: rgba(255,154,118,0.15);
|
|
1918
|
-
color: var(--accent);
|
|
1919
|
-
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
|
1920
|
-
}
|
|
1921
|
-
.onboarding-card .ob-step-title {
|
|
1922
|
-
font-family: var(--sans); font-size: 13px; font-weight: 600;
|
|
1923
|
-
color: var(--fg); margin-bottom: 2px;
|
|
1924
|
-
}
|
|
1925
|
-
.onboarding-card .ob-step-body {
|
|
1926
|
-
font-family: var(--sans); font-size: 12px; line-height: 1.5;
|
|
1927
|
-
color: var(--fg-50);
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
.intake { padding: 36px 0 24px; position: relative; z-index: 1; }
|
|
1931
|
-
.intake h1 {
|
|
1932
|
-
margin: 0 0 6px;
|
|
1933
|
-
font-size: clamp(28px, 4vw, 44px);
|
|
1934
|
-
font-weight: 900;
|
|
1935
|
-
letter-spacing: -0.02em;
|
|
1936
|
-
line-height: 1.08;
|
|
1937
|
-
}
|
|
1938
|
-
.intake p.sub { margin: 0 0 22px; color: var(--fg-50); font-size: 14px; max-width: 560px; }
|
|
1939
|
-
.intake textarea {
|
|
1940
|
-
width: 100%; min-height: 84px;
|
|
1941
|
-
background: var(--bg-1); color: var(--fg);
|
|
1942
|
-
border: 1px solid var(--border); border-radius: 10px;
|
|
1943
|
-
padding: 14px 16px;
|
|
1944
|
-
font-family: var(--mono); font-size: 13px; line-height: 1.55;
|
|
1945
|
-
resize: vertical; outline: none;
|
|
1946
|
-
}
|
|
1947
|
-
.intake textarea:focus { border-color: rgba(255,154,118,0.4); }
|
|
1948
|
-
|
|
1949
|
-
/* Pattern 5 · Composer + cost row. Sits between the textarea and the
|
|
1950
|
-
intake-row so the user sees: who's going to run this · where ·
|
|
1951
|
-
how much of the context window the prompt consumes · est. cost.
|
|
1952
|
-
Live-updates on every keystroke + agent/cwd change.
|
|
1953
|
-
Per docs/design/AGENTIC_UX.md Pattern 5. */
|
|
1954
|
-
.composer-meta {
|
|
1955
|
-
display: flex; gap: 10px; align-items: center;
|
|
1956
|
-
margin-top: 6px; padding: 0 4px;
|
|
1957
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
1958
|
-
min-height: 18px;
|
|
1959
|
-
}
|
|
1960
|
-
.composer-meta .cm-pill {
|
|
1961
|
-
display: inline-flex; gap: 4px; align-items: center;
|
|
1962
|
-
padding: 1px 8px; border-radius: 999px;
|
|
1963
|
-
border: 1px solid var(--border-soft);
|
|
1964
|
-
background: rgba(255,255,255,0.02);
|
|
1965
|
-
}
|
|
1966
|
-
.composer-meta .cm-pill b { color: var(--fg-70); font-weight: 500; }
|
|
1967
|
-
.composer-meta .cm-sep { color: var(--fg-20); user-select: none; }
|
|
1968
|
-
.composer-meta .cm-budget[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1969
|
-
.composer-meta .cm-budget[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1970
|
-
.composer-meta .cm-cost.free { color: var(--allow); border-color: rgba(40,200,64,0.22); background: rgba(40,200,64,0.06); }
|
|
1971
|
-
.composer-meta .cm-cost.paid { color: var(--fg-70); }
|
|
1972
|
-
.composer-meta .cm-spend { color: var(--fg-70); cursor: help; }
|
|
1973
|
-
.composer-meta .cm-spend b { color: var(--fg-90); }
|
|
1974
|
-
.composer-meta .cm-spend[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1975
|
-
.composer-meta .cm-spend[data-state="warn"] b{ color: var(--warn); }
|
|
1976
|
-
.composer-meta .cm-spend[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1977
|
-
.composer-meta .cm-spend[data-state="block"] b{ color: var(--block); }
|
|
1978
|
-
.composer-meta .cm-spacer { flex: 1; }
|
|
1979
|
-
.composer-meta .cm-hint {
|
|
1980
|
-
color: var(--fg-30); font-size: 10.5px;
|
|
1981
|
-
margin-left: 4px;
|
|
1982
|
-
}
|
|
1983
|
-
/* On narrow viewports the hint hides — agent + budget pills stay,
|
|
1984
|
-
keyboard hint becomes discoverable via tooltip on the textarea. */
|
|
1985
|
-
@media (max-width: 720px) {
|
|
1986
|
-
.composer-meta .cm-hint { display: none; }
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
.intake-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
|
1990
|
-
.intake select, .intake button, .intake input.cwd {
|
|
1991
|
-
font-family: var(--mono); font-size: 12px;
|
|
1992
|
-
background: var(--bg-1); color: var(--fg);
|
|
1993
|
-
border: 1px solid var(--border); border-radius: 8px;
|
|
1994
|
-
padding: 8px 14px;
|
|
1995
|
-
cursor: pointer;
|
|
1996
|
-
}
|
|
1997
|
-
.intake input.cwd {
|
|
1998
|
-
cursor: text;
|
|
1999
|
-
flex: 1 1 260px;
|
|
2000
|
-
min-width: 200px;
|
|
2001
|
-
}
|
|
2002
|
-
.intake input.cwd:focus { outline: none; border-color: rgba(255,154,118,0.4); }
|
|
2003
|
-
.intake input.cwd::placeholder { color: var(--fg-30); }
|
|
2004
|
-
.intake button.spawn {
|
|
2005
|
-
background: var(--accent); color: #000; font-weight: 600; border-color: var(--accent);
|
|
2006
|
-
}
|
|
2007
|
-
.intake button.spawn:hover { filter: brightness(1.05); }
|
|
2008
|
-
.intake button.spawn[disabled] { opacity: 0.45; cursor: default; }
|
|
2009
|
-
/* v0.6.x Slack-shape spike — alternative submit that routes the prompt as a
|
|
2010
|
-
reply to the most-recent task in the current cwd, exercising the existing
|
|
2011
|
-
appendMessage path. Lets a single intake feel like an ongoing conversation
|
|
2012
|
-
when the user wants continuity, while preserving "Assign" as the explicit
|
|
2013
|
-
new-task path. Disabled when no thread exists for the current cwd. */
|
|
2014
|
-
.intake button.continue-thread {
|
|
2015
|
-
background: rgba(255,154,118,0.10); color: var(--accent); font-weight: 600;
|
|
2016
|
-
border-color: rgba(255,154,118,0.3);
|
|
2017
|
-
}
|
|
2018
|
-
.intake button.continue-thread:hover:not([disabled]) { background: rgba(255,154,118,0.18); }
|
|
2019
|
-
.intake button.continue-thread[disabled] { opacity: 0.4; cursor: default; }
|
|
2020
|
-
.intake .hint { margin-left: auto; color: var(--fg-40); font-family: var(--mono); font-size: 11px; }
|
|
2021
|
-
|
|
2022
|
-
/* ── Setup panel (onboarding — appears when a non-ready agent is picked) ───
|
|
2023
|
-
Principle: codehere is the control plane from install → orchestrate → audit.
|
|
2024
|
-
When the user picks an agent that isn't ready, we surface what to do
|
|
2025
|
-
inline rather than fail at spawn time. */
|
|
2026
|
-
.setup-panel {
|
|
2027
|
-
margin: 10px 0 0;
|
|
2028
|
-
padding: 12px 14px;
|
|
2029
|
-
border: 1px solid var(--border);
|
|
2030
|
-
border-radius: 8px;
|
|
2031
|
-
background: rgba(254,188,46,0.04);
|
|
2032
|
-
font-family: var(--mono); font-size: 11px;
|
|
2033
|
-
color: var(--fg-70);
|
|
2034
|
-
display: none;
|
|
2035
|
-
}
|
|
2036
|
-
.setup-panel[data-state="needs_install"] { border-color: rgba(74,144,226,0.3); background: rgba(74,144,226,0.05); }
|
|
2037
|
-
.setup-panel[data-state="needs_auth"] { border-color: rgba(254,188,46,0.4); background: rgba(254,188,46,0.06); }
|
|
2038
|
-
.setup-panel[data-state="billed_key_required"] { border-color: rgba(255,95,87,0.35); background: rgba(255,95,87,0.05); }
|
|
2039
|
-
.setup-panel .sp-title {
|
|
2040
|
-
font-weight: 600; color: var(--fg); margin-bottom: 6px;
|
|
2041
|
-
}
|
|
2042
|
-
.setup-panel[data-state="needs_install"] .sp-title { color: var(--accent-blue); }
|
|
2043
|
-
.setup-panel[data-state="needs_auth"] .sp-title { color: var(--warn); }
|
|
2044
|
-
.setup-panel[data-state="billed_key_required"] .sp-title { color: var(--block); }
|
|
2045
|
-
.setup-panel code {
|
|
2046
|
-
background: rgba(255,255,255,0.08); padding: 3px 8px; border-radius: 4px;
|
|
2047
|
-
color: var(--fg); font-size: 11px;
|
|
2048
|
-
}
|
|
2049
|
-
.setup-panel a { color: var(--accent); text-decoration: none; }
|
|
2050
|
-
.setup-panel a:hover { text-decoration: underline; }
|
|
2051
|
-
.setup-panel .swap { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
2052
|
-
.setup-panel .swap .swap-label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
|
2053
|
-
.setup-panel .swap button {
|
|
2054
|
-
font-family: var(--mono); font-size: 11px;
|
|
2055
|
-
color: var(--accent); background: transparent;
|
|
2056
|
-
border: 1px solid rgba(255,154,118,0.35); border-radius: 999px;
|
|
2057
|
-
padding: 3px 10px; cursor: pointer;
|
|
2058
|
-
}
|
|
2059
|
-
.setup-panel .swap button:hover { color: var(--fg); background: rgba(255,154,118,0.1); border-color: var(--accent); }
|
|
2060
|
-
|
|
2061
|
-
/* F2 · Install button + streamed output (principle #10: codehere DOES the install) */
|
|
2062
|
-
.setup-panel .install-row { margin-top: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
2063
|
-
.setup-panel .install-row button.install {
|
|
2064
|
-
background: var(--accent); color: #000; font-weight: 600;
|
|
2065
|
-
border: 1px solid var(--accent); border-radius: 8px;
|
|
2066
|
-
padding: 8px 16px; cursor: pointer;
|
|
2067
|
-
font-family: var(--sans); font-size: 12px;
|
|
2068
|
-
}
|
|
2069
|
-
.setup-panel .install-row button.install:hover { filter: brightness(1.05); }
|
|
2070
|
-
.setup-panel .install-row button.install[disabled] { opacity: 0.5; cursor: default; }
|
|
2071
|
-
.setup-panel .install-row button.install.running { background: transparent; color: var(--accent); }
|
|
2072
|
-
.setup-panel .install-row button.install.done { background: rgba(40,200,64,0.1); color: var(--allow); border-color: rgba(40,200,64,0.35); }
|
|
2073
|
-
.setup-panel .install-row button.install.failed { background: rgba(255,95,87,0.08); color: var(--block); border-color: rgba(255,95,87,0.35); }
|
|
2074
|
-
.setup-panel .install-row .install-subtitle { color: var(--fg-50); font-size: 11px; font-family: var(--mono); }
|
|
2075
|
-
.setup-panel .install-output {
|
|
2076
|
-
margin-top: 10px; padding: 10px 12px;
|
|
2077
|
-
background: #000; border: 1px solid var(--border-soft); border-radius: 6px;
|
|
2078
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-70);
|
|
2079
|
-
max-height: 220px; overflow-y: auto;
|
|
2080
|
-
white-space: pre-wrap; word-break: break-word;
|
|
2081
|
-
display: none;
|
|
2082
|
-
}
|
|
2083
|
-
.setup-panel .install-output.show { display: block; }
|
|
2084
|
-
.setup-panel .install-output.has-err { border-color: rgba(255,95,87,0.3); }
|
|
2085
|
-
.setup-panel .install-details-toggle {
|
|
2086
|
-
font-family: var(--mono); font-size: 10px; color: var(--fg-40);
|
|
2087
|
-
background: transparent; border: none; cursor: pointer;
|
|
2088
|
-
padding: 2px 6px;
|
|
2089
|
-
}
|
|
2090
|
-
.setup-panel .install-details-toggle:hover { color: var(--fg-70); }
|
|
2091
|
-
|
|
2092
|
-
/* Per-option glyph in the select dropdown — rendered via the option text itself
|
|
2093
|
-
since <option> can't be styled with pseudo-elements cross-browser. */
|
|
2094
|
-
|
|
2095
|
-
/* ── Starter prompts (Gate A1 — first-run path to success) ──────────
|
|
2096
|
-
Principle #1 (web is not a terminal — a welcoming surface) + scope-discipline #9.
|
|
2097
|
-
Three one-click prompts that let a stranger see codehere work in the project
|
|
2098
|
-
they opened the daemon in, without needing to know what to ask. */
|
|
2099
|
-
.starter-prompts {
|
|
2100
|
-
display: flex; flex-wrap: wrap; gap: 6px;
|
|
2101
|
-
margin: 10px 0 0;
|
|
2102
|
-
}
|
|
2103
|
-
.starter-prompts button {
|
|
2104
|
-
font-family: var(--mono); font-size: 11px;
|
|
2105
|
-
color: var(--fg-70);
|
|
2106
|
-
border: 1px solid var(--border); border-radius: 999px;
|
|
2107
|
-
background: rgba(255,255,255,0.02);
|
|
2108
|
-
padding: 5px 12px; cursor: pointer;
|
|
2109
|
-
transition: color .15s ease, border-color .15s ease, background .15s ease;
|
|
2110
|
-
}
|
|
2111
|
-
.starter-prompts button:hover {
|
|
2112
|
-
color: var(--fg);
|
|
2113
|
-
border-color: rgba(255,154,118,0.35);
|
|
2114
|
-
background: rgba(255,154,118,0.06);
|
|
2115
|
-
}
|
|
2116
|
-
.starter-prompts .sp-label {
|
|
2117
|
-
font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
|
|
2118
|
-
color: var(--fg-40); text-transform: uppercase;
|
|
2119
|
-
padding: 5px 2px 5px 0;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
.section-label {
|
|
2123
|
-
margin-top: 24px; margin-bottom: 8px;
|
|
2124
|
-
font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
|
|
2125
|
-
color: var(--fg-40); text-transform: uppercase;
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
.session {
|
|
2129
|
-
border-radius: 12px;
|
|
2130
|
-
margin-bottom: 12px;
|
|
2131
|
-
overflow: hidden;
|
|
2132
|
-
/* glass-card styling from shared tokens */
|
|
2133
|
-
background: rgba(255,255,255,0.02);
|
|
2134
|
-
backdrop-filter: blur(16px);
|
|
2135
|
-
-webkit-backdrop-filter: blur(16px);
|
|
2136
|
-
border: 1px solid var(--border);
|
|
2137
|
-
transition: border-color 0.3s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1), box-shadow 0.3s ease;
|
|
2138
|
-
animation: fade-up 0.5s cubic-bezier(0.16,1,0.3,1) both;
|
|
2139
|
-
}
|
|
2140
|
-
.session:hover {
|
|
2141
|
-
border-color: rgba(255,255,255,0.15);
|
|
2142
|
-
transform: translateY(-2px);
|
|
2143
|
-
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.04);
|
|
2144
|
-
}
|
|
2145
|
-
.session-head {
|
|
2146
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
2147
|
-
padding: 14px 20px;
|
|
2148
|
-
cursor: pointer;
|
|
2149
|
-
user-select: none;
|
|
2150
|
-
}
|
|
2151
|
-
.session.expanded .session-head {
|
|
2152
|
-
border-bottom: 1px solid var(--border-soft);
|
|
2153
|
-
}
|
|
2154
|
-
.session .chevron {
|
|
2155
|
-
display: inline-block; margin-left: 8px; color: var(--fg-40);
|
|
2156
|
-
font-family: var(--mono); font-size: 10px;
|
|
2157
|
-
transition: transform 0.2s ease;
|
|
2158
|
-
}
|
|
2159
|
-
.session.expanded .chevron { transform: rotate(90deg); }
|
|
2160
|
-
.session-body { display: none; }
|
|
2161
|
-
.session.expanded .session-body { display: block; }
|
|
2162
|
-
.session-head .meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
2163
|
-
.session-head .prompt { font-family: var(--mono); font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 620px; }
|
|
2164
|
-
.session-head .sub { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
|
|
2165
|
-
/* May 6 2026 design polish (Tier 1 #2): drop uppercase + heavy letter-spacing
|
|
2166
|
-
per Open-Agents production-grade reference. Status pills now read as
|
|
2167
|
-
human labels (Sentence case) not all-caps shouts. Killed demoted from
|
|
2168
|
-
yellow-warn to muted neutral — it's just "stopped", not an alert. */
|
|
2169
|
-
.session-head .status-pill {
|
|
2170
|
-
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
|
|
2171
|
-
padding: 3px 10px; border-radius: 999px;
|
|
2172
|
-
border: 1px solid transparent;
|
|
2173
|
-
}
|
|
2174
|
-
/* Pattern 8 · Status badge taxonomy — locked palette per Track A #2.
|
|
2175
|
-
7 owned states; semantic colors reflect actionable meaning.
|
|
2176
|
-
Per docs/design/AGENTIC_UX.md Pattern 8. */
|
|
2177
|
-
.status-pill.running { color: var(--accent); background: var(--accent-wash); border-color: rgba(255,154,118,0.25); }
|
|
2178
|
-
.status-pill.completed { color: var(--allow); background: rgba(40,200,64,0.10); border-color: rgba(40,200,64,0.20); }
|
|
2179
|
-
.status-pill.awaiting { color: var(--accent); background: transparent; border-color: var(--accent); border-style: dashed; }
|
|
2180
|
-
.status-pill.blocked { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
|
|
2181
|
-
.status-pill.failed,
|
|
2182
|
-
.status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2183
|
-
.status-pill.killed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
|
|
2184
|
-
.status-pill.observed-pill { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); font-weight: 400; }
|
|
2185
|
-
|
|
2186
|
-
/* Pattern 6 · macro states — wired to actual adapter error codes
|
|
2187
|
-
emitted by failover-classifier.ts + per-adapter ERROR_PATTERNS.
|
|
2188
|
-
yellow (--warn) = recoverable (failover-eligible per failover-classifier);
|
|
2189
|
-
red (--block) = needs human action (auth, billing, model-pick).
|
|
2190
|
-
Per docs/design/AGENTIC_UX.md Pattern 6. */
|
|
2191
|
-
.status-pill.rate-limit,
|
|
2192
|
-
.status-pill.quota-exceeded,
|
|
2193
|
-
.status-pill.credit-low { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
|
|
2194
|
-
.status-pill.auth-invalid,
|
|
2195
|
-
.status-pill.auth-missing,
|
|
2196
|
-
.status-pill.model-unavailable,
|
|
2197
|
-
.status-pill.crashed { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2198
|
-
.status-pill.thinking,
|
|
2199
|
-
.status-pill.running-buffered { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); }
|
|
2200
|
-
|
|
2201
|
-
/* Persisted sessions = your own past tasks replayed from disk.
|
|
2202
|
-
Faded but VISIBLE. The detected-process variant (.observed) is
|
|
2203
|
-
diagnostic — hidden by default per claim_user_persona. */
|
|
2204
|
-
.session.persisted { opacity: 0.88; }
|
|
2205
|
-
.session.persisted .session-head { border-bottom: none; }
|
|
2206
|
-
.session.observed { opacity: 0.88; }
|
|
2207
|
-
.session.observed .session-head { border-bottom: none; }
|
|
2208
|
-
/* claim_user_persona HIGH — detected-process rows ("Claude Code ·
|
|
2209
|
-
pid 30531") are diagnostic, not user-facing. Hidden by default;
|
|
2210
|
-
advanced toggle reveals. The user's OWN spawned + persisted
|
|
2211
|
-
sessions still show — only externally-detected runners are gated. */
|
|
2212
|
-
.session.observed { display: none; }
|
|
2213
|
-
body.advanced-on .session.observed { display: block; }
|
|
2214
|
-
|
|
2215
|
-
/* claim_user_persona — session list limit. Default shows the 5
|
|
2216
|
-
most-recent cards; "Show all" reveals the rest. .show-all-on body
|
|
2217
|
-
class lifts the cap. CSS-only via :nth-child so attachSession
|
|
2218
|
-
prepend semantics stay simple.
|
|
2219
|
-
CRITICAL: running sessions are NEVER capped — a user must always
|
|
2220
|
-
see their actively-working session, even if it's the 10th card. */
|
|
2221
|
-
#sessions > .session:nth-child(n+6):not(.status-running) { display: none; }
|
|
2222
|
-
body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
|
|
2223
|
-
/* When advanced is also off, observed rows beyond cap stay hidden. */
|
|
2224
|
-
body.show-all-sessions #sessions > .session.observed { display: none; }
|
|
2225
|
-
body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
|
|
2226
|
-
.sessions-actions {
|
|
2227
|
-
display: flex; gap: 10px; justify-content: center; align-items: center;
|
|
2228
|
-
margin: 14px 0 0;
|
|
2229
|
-
}
|
|
2230
|
-
.show-more-sessions {
|
|
2231
|
-
display: none; /* shown via JS when count > 5 */
|
|
2232
|
-
padding: 8px 16px;
|
|
2233
|
-
background: transparent;
|
|
2234
|
-
color: var(--fg-50);
|
|
2235
|
-
border: 1px solid var(--border);
|
|
2236
|
-
border-radius: 999px;
|
|
2237
|
-
font-family: var(--mono); font-size: 11px;
|
|
2238
|
-
cursor: pointer;
|
|
2239
|
-
transition: color 0.15s ease, border-color 0.15s ease;
|
|
2240
|
-
}
|
|
2241
|
-
.show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2242
|
-
body.show-all-sessions .show-more-sessions { display: none; }
|
|
2243
|
-
.stop-all-sessions {
|
|
2244
|
-
padding: 8px 16px;
|
|
2245
|
-
background: transparent;
|
|
2246
|
-
color: var(--block);
|
|
2247
|
-
border: 1px solid rgba(255,95,87,0.3);
|
|
2248
|
-
border-radius: 999px;
|
|
2249
|
-
font-family: var(--mono); font-size: 11px;
|
|
2250
|
-
cursor: pointer;
|
|
2251
|
-
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
|
2252
|
-
}
|
|
2253
|
-
.stop-all-sessions:hover { color: var(--fg); border-color: var(--block); background: rgba(255,95,87,0.08); }
|
|
2254
|
-
|
|
2255
|
-
.session-controls { display: flex; gap: 8px; }
|
|
2256
|
-
.session-controls button {
|
|
2257
|
-
font-family: var(--mono); font-size: 11px;
|
|
2258
|
-
background: transparent; color: var(--fg-70);
|
|
2259
|
-
border: 1px solid var(--border); border-radius: 6px;
|
|
2260
|
-
padding: 4px 10px; cursor: pointer;
|
|
2261
|
-
}
|
|
2262
|
-
.session-controls button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2263
|
-
.session-controls button.kill:hover { color: var(--block); border-color: rgba(255,95,87,0.4); }
|
|
2264
|
-
/* Pattern 19 — interrupt affordance state machine (ready / confirming / killing).
|
|
2265
|
-
Confirming state turns the button red so the second press registers
|
|
2266
|
-
as intentional. 3s window then reverts to ready. */
|
|
2267
|
-
.session-controls button.kill[data-state="confirming"] {
|
|
2268
|
-
color: var(--block);
|
|
2269
|
-
border-color: var(--block);
|
|
2270
|
-
background: rgba(255,95,87,0.08);
|
|
2271
|
-
font-weight: 600;
|
|
2272
|
-
animation: kill-confirm-pulse 0.6s ease-in-out infinite alternate;
|
|
2273
|
-
}
|
|
2274
|
-
.session-controls button.kill[data-state="killing"] {
|
|
2275
|
-
color: var(--fg-50);
|
|
2276
|
-
cursor: progress;
|
|
2277
|
-
opacity: 0.7;
|
|
2278
|
-
}
|
|
2279
|
-
@keyframes kill-confirm-pulse {
|
|
2280
|
-
from { box-shadow: 0 0 0 rgba(255,95,87,0); }
|
|
2281
|
-
to { box-shadow: 0 0 8px rgba(255,95,87,0.4); }
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
.session-body { padding: 0 20px 16px; }
|
|
2285
|
-
|
|
2286
|
-
/* Session threading — visual link between original and failover sessions.
|
|
2287
|
-
The original card shows "↻ continued as [agent]" with a link to the new card.
|
|
2288
|
-
The new card shows "Continued from [agent]" at the top. */
|
|
2289
|
-
.session-continued-as {
|
|
2290
|
-
margin: 0 20px 12px;
|
|
2291
|
-
padding: 8px 12px;
|
|
2292
|
-
border: 1px dashed rgba(255,154,118,0.3);
|
|
2293
|
-
border-radius: 8px;
|
|
2294
|
-
background: rgba(255,154,118,0.04);
|
|
2295
|
-
font-family: var(--mono);
|
|
2296
|
-
font-size: 11px;
|
|
2297
|
-
color: var(--fg-70);
|
|
2298
|
-
}
|
|
2299
|
-
.session-continued-as a {
|
|
2300
|
-
color: var(--accent);
|
|
2301
|
-
text-decoration: none;
|
|
2302
|
-
font-weight: 500;
|
|
2303
|
-
}
|
|
2304
|
-
.session-continued-as a:hover { text-decoration: underline; }
|
|
2305
|
-
.session-continued-from {
|
|
2306
|
-
margin: 0 0 8px;
|
|
2307
|
-
padding: 6px 12px;
|
|
2308
|
-
border-left: 2px solid rgba(255,154,118,0.3);
|
|
2309
|
-
font-family: var(--mono);
|
|
2310
|
-
font-size: 10.5px;
|
|
2311
|
-
color: var(--fg-50);
|
|
2312
|
-
}
|
|
2313
|
-
.session-continued-from a {
|
|
2314
|
-
color: var(--accent);
|
|
2315
|
-
text-decoration: none;
|
|
2316
|
-
}
|
|
2317
|
-
.session-continued-from a:hover { text-decoration: underline; }
|
|
2318
|
-
|
|
2319
|
-
/* P18 slice 4 — permission gate panel. Renders inside the session
|
|
2320
|
-
card when the per-tool hook blocked something. Per the dogfood-
|
|
2321
|
-
learning + overconfidence-guard: framed as "Codehere noticed",
|
|
2322
|
-
not "security violation". Approve button is the most prominent
|
|
2323
|
-
because false-positives are the common case (e.g. /etc/shadow
|
|
2324
|
-
in a docstring). Keep-blocking is the rare-but-real branch. */
|
|
2325
|
-
.permission-gate {
|
|
2326
|
-
margin: 10px 0 8px;
|
|
2327
|
-
padding: 12px 14px;
|
|
2328
|
-
border: 1px solid rgba(254,188,46,0.35);
|
|
2329
|
-
background: rgba(254,188,46,0.05);
|
|
2330
|
-
border-radius: 8px;
|
|
2331
|
-
}
|
|
2332
|
-
.permission-gate .pg-label {
|
|
2333
|
-
font-family: var(--mono); font-size: 9.5px;
|
|
2334
|
-
color: var(--warn);
|
|
2335
|
-
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2336
|
-
margin-bottom: 4px;
|
|
2337
|
-
}
|
|
2338
|
-
.permission-gate .pg-title {
|
|
2339
|
-
font-family: var(--sans); font-size: 13px;
|
|
2340
|
-
color: var(--fg);
|
|
2341
|
-
margin-bottom: 6px;
|
|
2342
|
-
}
|
|
2343
|
-
.permission-gate .pg-detail {
|
|
2344
|
-
font-family: var(--mono); font-size: 11px;
|
|
2345
|
-
color: var(--fg-70);
|
|
2346
|
-
background: rgba(0,0,0,0.2);
|
|
2347
|
-
padding: 6px 10px; border-radius: 4px;
|
|
2348
|
-
margin-bottom: 10px;
|
|
2349
|
-
word-break: break-all;
|
|
2350
|
-
}
|
|
2351
|
-
.permission-gate .pg-actions {
|
|
2352
|
-
display: flex; gap: 8px; align-items: center;
|
|
2353
|
-
}
|
|
2354
|
-
.permission-gate button {
|
|
2355
|
-
font-family: var(--mono); font-size: 11px;
|
|
2356
|
-
padding: 6px 12px; border-radius: 6px;
|
|
2357
|
-
cursor: pointer;
|
|
2358
|
-
border: 1px solid var(--border);
|
|
2359
|
-
background: transparent;
|
|
2360
|
-
color: var(--fg-70);
|
|
2361
|
-
}
|
|
2362
|
-
.permission-gate button.pg-approve {
|
|
2363
|
-
color: var(--accent);
|
|
2364
|
-
border-color: var(--accent);
|
|
2365
|
-
background: var(--accent-wash);
|
|
2366
|
-
font-weight: 600;
|
|
2367
|
-
}
|
|
2368
|
-
.permission-gate button.pg-approve:hover { filter: brightness(1.1); }
|
|
2369
|
-
.permission-gate button.pg-keep:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2370
|
-
.permission-gate.approved {
|
|
2371
|
-
border-color: rgba(40,200,64,0.3);
|
|
2372
|
-
background: rgba(40,200,64,0.05);
|
|
2373
|
-
}
|
|
2374
|
-
.permission-gate.approved .pg-label { color: var(--allow); }
|
|
2375
|
-
.permission-gate.approved .pg-actions { display: none; }
|
|
2376
|
-
.permission-gate .pg-status {
|
|
2377
|
-
display: none;
|
|
2378
|
-
font-family: var(--mono); font-size: 10.5px;
|
|
2379
|
-
color: var(--allow);
|
|
2380
|
-
margin-top: 2px;
|
|
2381
|
-
}
|
|
2382
|
-
.permission-gate.approved .pg-status { display: block; }
|
|
2383
|
-
|
|
2384
|
-
/* v0.6 M3 — PM narrator block. Quiet style — visible but doesn't
|
|
2385
|
-
compete with primary content. Italic body to signal "this is
|
|
2386
|
-
narration, not authoritative" per overconfidence-guard. */
|
|
2387
|
-
.narrator-block {
|
|
2388
|
-
margin: 10px 0 8px;
|
|
2389
|
-
padding: 10px 14px;
|
|
2390
|
-
border-left: 2px solid var(--accent);
|
|
2391
|
-
background: rgba(255,154,118,0.04);
|
|
2392
|
-
border-radius: 0 8px 8px 0;
|
|
2393
|
-
}
|
|
2394
|
-
.narrator-block .narrator-label {
|
|
2395
|
-
font-family: var(--mono); font-size: 9.5px;
|
|
2396
|
-
color: var(--fg-30);
|
|
2397
|
-
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2398
|
-
margin-bottom: 4px;
|
|
2399
|
-
}
|
|
2400
|
-
.narrator-block .narrator-text {
|
|
2401
|
-
font-family: var(--sans);
|
|
2402
|
-
font-size: 13px; line-height: 1.45;
|
|
2403
|
-
color: var(--fg-70);
|
|
2404
|
-
font-style: italic;
|
|
2405
|
-
}
|
|
2406
|
-
.narrator-block .narrator-meta {
|
|
2407
|
-
font-family: var(--mono); font-size: 10px;
|
|
2408
|
-
color: var(--fg-30);
|
|
2409
|
-
margin-top: 4px;
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
/* Pattern 3 · Session card footer — elapsed · tools · files. Renders
|
|
2413
|
-
beneath the body when the session has at least one step. Closes the
|
|
2414
|
-
card visually + surfaces the same counts as the live tool-progress
|
|
2415
|
-
chip but in a settled, summary form. Cost intentionally absent here
|
|
2416
|
-
— that's P5's composer surface, not the per-card footer (per
|
|
2417
|
-
AGENTIC_UX.md Pattern 3: "elapsed · cost · tool count · file count"
|
|
2418
|
-
with cost-router not yet wired). */
|
|
2419
|
-
.session-foot {
|
|
2420
|
-
display: flex; gap: 14px; align-items: center;
|
|
2421
|
-
padding: 10px 20px 12px;
|
|
2422
|
-
border-top: 1px solid var(--border-soft);
|
|
2423
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
2424
|
-
}
|
|
2425
|
-
.session-foot[data-empty="true"] { display: none; }
|
|
2426
|
-
.session-foot .sf-item { display: inline-flex; gap: 4px; align-items: center; }
|
|
2427
|
-
.session-foot .sf-item b { color: var(--fg-70); font-weight: 600; }
|
|
2428
|
-
.session-foot .sf-sep { color: var(--fg-20); user-select: none; }
|
|
2429
|
-
|
|
2430
|
-
.event-log {
|
|
2431
|
-
margin: 10px 0 0;
|
|
2432
|
-
max-height: 360px; overflow-y: auto;
|
|
2433
|
-
background: #000;
|
|
2434
|
-
border: 1px solid var(--border-soft); border-radius: 8px;
|
|
2435
|
-
padding: 10px 12px;
|
|
2436
|
-
font-family: var(--mono); font-size: 11px; line-height: 1.6;
|
|
2437
|
-
}
|
|
2438
|
-
.event-log .event { color: var(--fg-70); white-space: pre-wrap; word-break: break-word; padding: 2px 0; }
|
|
2439
|
-
.event-log .event .t { color: var(--fg-30); margin-right: 8px; }
|
|
2440
|
-
.event-log .event .label { color: var(--fg-50); font-weight: 600; margin-right: 6px; }
|
|
2441
|
-
.event-log .event.claude { color: var(--fg); }
|
|
2442
|
-
.event-log .event.claude .label { color: var(--accent); }
|
|
2443
|
-
.event-log .event.tool .label { color: var(--accent); }
|
|
2444
|
-
.event-log .event.tool .tool-name { color: var(--accent); font-weight: 600; }
|
|
2445
|
-
.event-log .event.result .label { color: var(--fg-50); }
|
|
2446
|
-
.event-log .event.hook .label { color: var(--allow); }
|
|
2447
|
-
.event-log .event.hook.blocked .label { color: var(--block); }
|
|
2448
|
-
.event-log .event.done .label { color: var(--allow); }
|
|
2449
|
-
.event-log .event.stderr { color: var(--warn); }
|
|
2450
|
-
.event-log .event.stderr .label { color: var(--warn); }
|
|
2451
|
-
.event-log .event.exit { color: var(--fg-50); font-style: italic; }
|
|
2452
|
-
.event-log .event.error { color: var(--block); }
|
|
2453
|
-
.event-log .event.error .label { color: var(--block); }
|
|
2454
|
-
|
|
2455
|
-
/* codehere:error events — structured recovery hints (F1b) */
|
|
2456
|
-
.event-log .event.codehere-error {
|
|
2457
|
-
margin: 6px -4px; padding: 10px 12px;
|
|
2458
|
-
background: rgba(255,95,87,0.08); border: 1px solid rgba(255,95,87,0.3); border-radius: 6px;
|
|
2459
|
-
color: var(--fg);
|
|
2460
|
-
}
|
|
2461
|
-
.event-log .event.codehere-error .label { color: var(--block); display: block; margin-bottom: 4px; }
|
|
2462
|
-
.event-log .event.codehere-error .recovery { color: var(--fg-70); margin-top: 4px; display: block; }
|
|
2463
|
-
|
|
2464
|
-
/* Apr 29 — Slack-shape thread view.
|
|
2465
|
-
Expanded session card defaults to thread view; raw event-log opt-in via
|
|
2466
|
-
the Thread/Log toggle in the card head. Each event becomes a message:
|
|
2467
|
-
user prompt seeds it; agent text/tool/hook events stream in as replies.
|
|
2468
|
-
The reply input is a visual scaffold — wires up as a real follow-up
|
|
2469
|
-
queue in a later release. */
|
|
2470
|
-
.thread-messages {
|
|
2471
|
-
margin: 10px 0 0;
|
|
2472
|
-
display: flex; flex-direction: column; gap: 10px;
|
|
2473
|
-
max-height: 420px; overflow-y: auto;
|
|
2474
|
-
padding-right: 4px;
|
|
2475
|
-
}
|
|
2476
|
-
.thread-message { display: flex; gap: 10px; padding: 6px 0; }
|
|
2477
|
-
.thread-message .avatar {
|
|
2478
|
-
flex: 0 0 28px; height: 28px;
|
|
2479
|
-
border-radius: 50%;
|
|
2480
|
-
display: flex; align-items: center; justify-content: center;
|
|
2481
|
-
font-size: 13px;
|
|
2482
|
-
background: rgba(255,255,255,0.04); border: 1px solid var(--border-soft);
|
|
2483
|
-
}
|
|
2484
|
-
.thread-message.user .avatar { background: rgba(74,144,226,0.18); border-color: rgba(74,144,226,0.45); }
|
|
2485
|
-
.thread-message.agent .avatar { background: rgba(255,154,118,0.18); border-color: rgba(255,154,118,0.45); }
|
|
2486
|
-
.thread-message.tool .avatar { background: rgba(255,154,118,0.08); }
|
|
2487
|
-
.thread-message.hook .avatar { background: rgba(127,193,154,0.18); border-color: rgba(127,193,154,0.4); }
|
|
2488
|
-
.thread-message.hook.blocked .avatar { background: rgba(255,95,87,0.18); border-color: rgba(255,95,87,0.45); }
|
|
2489
|
-
.thread-message.error .avatar { background: rgba(255,95,87,0.18); border-color: rgba(255,95,87,0.45); }
|
|
2490
|
-
.thread-message .body { flex: 1; min-width: 0; }
|
|
2491
|
-
.thread-message .head { display: flex; gap: 8px; align-items: baseline; font-family: var(--mono); font-size: 10.5px; }
|
|
2492
|
-
.thread-message .author { color: var(--fg); font-weight: 600; }
|
|
2493
|
-
.thread-message .time { color: var(--fg-40); }
|
|
2494
|
-
.thread-message .content {
|
|
2495
|
-
color: var(--fg-80); font-family: var(--mono); font-size: 11.5px;
|
|
2496
|
-
line-height: 1.55; margin-top: 3px; white-space: pre-wrap; word-break: break-word;
|
|
2497
|
-
}
|
|
2498
|
-
.thread-message.tool .content { color: var(--fg-60); }
|
|
2499
|
-
.thread-message.hook .content { color: var(--allow); }
|
|
2500
|
-
.thread-message.hook.blocked .content { color: var(--block); }
|
|
2501
|
-
.thread-message.error .content { color: var(--block); }
|
|
2502
|
-
|
|
2503
|
-
.thread-input {
|
|
2504
|
-
margin-top: 14px;
|
|
2505
|
-
padding: 10px 12px;
|
|
2506
|
-
background: rgba(255,255,255,0.02);
|
|
2507
|
-
border: 1px solid var(--border-soft); border-radius: 8px;
|
|
2508
|
-
color: var(--fg-50); font-size: 11px; font-family: var(--mono);
|
|
2509
|
-
display: flex; align-items: center; gap: 8px;
|
|
2510
|
-
}
|
|
2511
|
-
/* Iter 12 — reply surface now functional. Wraps textarea + send button. */
|
|
2512
|
-
.thread-input { padding: 8px; flex-direction: column; align-items: stretch; gap: 6px; }
|
|
2513
|
-
.thread-input::before { display: none; }
|
|
2514
|
-
.thread-input .reply-text {
|
|
2515
|
-
width: 100%; min-height: 38px; resize: vertical;
|
|
2516
|
-
background: transparent; border: 0; outline: none;
|
|
2517
|
-
color: var(--fg); font-family: var(--sans); font-size: 12px; line-height: 1.5;
|
|
2518
|
-
padding: 4px 0;
|
|
2519
|
-
}
|
|
2520
|
-
.thread-input .reply-text::placeholder { color: var(--fg-30); }
|
|
2521
|
-
.thread-input .reply-send {
|
|
2522
|
-
align-self: flex-end;
|
|
2523
|
-
padding: 5px 14px; border-radius: 6px;
|
|
2524
|
-
background: var(--accent-wash); color: var(--accent);
|
|
2525
|
-
border: 1px solid rgba(255,154,118,0.25);
|
|
2526
|
-
font-family: var(--sans); font-size: 11px; font-weight: 600;
|
|
2527
|
-
cursor: pointer;
|
|
2528
|
-
}
|
|
2529
|
-
.thread-input .reply-send:hover { background: rgba(255,154,118,0.18); }
|
|
2530
|
-
.thread-input .reply-send:disabled { opacity: 0.4; cursor: default; }
|
|
2531
|
-
.thread-input .reply-status { color: var(--fg-50); font-size: 10px; font-family: var(--mono); }
|
|
2532
|
-
.thread-input .reply-status.error { color: var(--block); }
|
|
2533
|
-
.thread-input.disabled .reply-text,
|
|
2534
|
-
.thread-input.disabled .reply-send { opacity: 0.5; cursor: not-allowed; }
|
|
2535
|
-
|
|
2536
|
-
.view-toggle {
|
|
2537
|
-
display: inline-flex; gap: 0;
|
|
2538
|
-
border: 1px solid var(--border-soft); border-radius: 6px;
|
|
2539
|
-
overflow: hidden;
|
|
2540
|
-
margin-right: 8px;
|
|
2541
|
-
}
|
|
2542
|
-
.view-toggle button {
|
|
2543
|
-
background: transparent; border: 0;
|
|
2544
|
-
color: var(--fg-60); padding: 3px 9px;
|
|
2545
|
-
font-family: var(--mono); font-size: 10px;
|
|
2546
|
-
cursor: pointer;
|
|
2547
|
-
}
|
|
2548
|
-
.view-toggle button.active { color: var(--fg); background: rgba(255,255,255,0.06); }
|
|
2549
|
-
.view-toggle button:hover { color: var(--fg); }
|
|
2550
|
-
|
|
2551
|
-
/* Toggle visibility based on data-view attribute on session-body */
|
|
2552
|
-
.session-body[data-view="thread"] .event-log { display: none; }
|
|
2553
|
-
.session-body[data-view="raw"] .thread-messages { display: none; }
|
|
2554
|
-
.session-body[data-view="raw"] .thread-input { display: none; }
|
|
2555
|
-
|
|
2556
|
-
/* Apr 29 — UX overhaul: collapsed single-line stats row (replaces stacked
|
|
2557
|
-
audit-summary + status-banner). Sits BELOW the intake, not above. Muted
|
|
2558
|
-
so it doesn't compete with the H1 / textarea for eye attention. */
|
|
2559
|
-
.stats-line {
|
|
2560
|
-
margin: 14px 0 0;
|
|
2561
|
-
font-family: var(--mono); font-size: 10.5px; color: var(--fg-40);
|
|
2562
|
-
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
|
2563
|
-
}
|
|
2564
|
-
.stats-line b { color: var(--fg-70); font-weight: 500; }
|
|
2565
|
-
.stats-line .sep { color: var(--fg-20); }
|
|
2566
|
-
.stats-line .blocked b { color: var(--block); }
|
|
2567
|
-
.stats-line.empty { display: none; }
|
|
2568
|
-
|
|
2569
|
-
/* Apr 29 — UX overhaul: readiness banner is now compact + below intake.
|
|
2570
|
-
Was: full-width yellow box with multi-line prose above the H1.
|
|
2571
|
-
Is: tighter padding, single-line preferred, sits as a subtle hint below
|
|
2572
|
-
the assign action. The detail text wraps when long but contrast is muted. */
|
|
2573
|
-
.readiness { margin-top: 14px; padding: 10px 12px; }
|
|
2574
|
-
.readiness .r-title { font-size: 11px; }
|
|
2575
|
-
.readiness .r-detail { font-size: 10.5px; }
|
|
2576
|
-
.readiness .r-action { margin-top: 6px; }
|
|
2577
|
-
|
|
2578
|
-
/* Apr 29 — UX overhaul: TRY chips fade out as soon as the user starts
|
|
2579
|
-
typing. They're a discovery aid for first-visit users, not a permanent
|
|
2580
|
-
fixture above an active textarea. */
|
|
2581
|
-
.starter-prompts.dim { opacity: 0.35; pointer-events: none; transition: opacity 200ms ease; }
|
|
2582
|
-
|
|
2583
|
-
#empty { padding: 48px 0; text-align: center; color: var(--fg-50); }
|
|
2584
|
-
#empty .pill { display: inline-block; padding: 6px 14px; border: 1px solid var(--border); border-radius: 999px; background: rgba(255,255,255,0.03); font-family: var(--mono); font-size: 11px; color: var(--fg-70); }
|
|
2585
|
-
</style>
|
|
2586
|
-
</head>
|
|
2587
|
-
<body>
|
|
2588
|
-
<nav class="floating-nav">
|
|
2589
|
-
<a href="/" class="logo">codehere</a>
|
|
2590
|
-
<a href="/" class="nav-link active">Tasks</a>
|
|
2591
|
-
<a href="/threads" class="nav-link">Threads</a>
|
|
2592
|
-
<a href="/history" class="nav-link">History</a>
|
|
2593
|
-
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
2594
|
-
<span class="mode-indicator" data-mode="" id="mode-indicator" title="Loading safety-hook mode…"><span class="mi-dot"></span><span class="mi-label">…</span></span>
|
|
2595
|
-
</nav>
|
|
2596
|
-
|
|
2597
|
-
<div class="shell slack-shape">
|
|
2598
|
-
|
|
2599
|
-
<aside class="channels-pane">
|
|
2600
|
-
<div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">0</span></div>
|
|
2601
|
-
<div id="channels-list"><div class="pane-empty">Loading…</div></div>
|
|
2602
|
-
</aside>
|
|
2603
|
-
|
|
2604
|
-
<section class="thread-pane">
|
|
2605
|
-
|
|
2606
|
-
<!-- v0.6 S2 — first-time onboarding card. Hidden once the user
|
|
2607
|
-
dismisses (localStorage flag codehere.onboarded=1) OR once any
|
|
2608
|
-
session exists in the dashboard. Per claim_user_persona HIGH:
|
|
2609
|
-
non-technical users primary; the first 5 minutes is load-bearing
|
|
2610
|
-
(feedback_first_5_minutes_is_load_bearing.md). This card guides
|
|
2611
|
-
those 5 minutes: pick an agent → type a task → watch the report. -->
|
|
2612
|
-
<div class="onboarding-card" id="onboarding-card" style="display:none">
|
|
2613
|
-
<div class="ob-head">
|
|
2614
|
-
<div class="ob-title">Welcome to codehere.</div>
|
|
2615
|
-
<button type="button" class="ob-close" id="ob-close" aria-label="Dismiss">×</button>
|
|
2616
|
-
</div>
|
|
2617
|
-
<div class="ob-blurb">A PM for your AI coding agents. Delegate a task in plain English; codehere picks an agent, watches every action, and reports back.</div>
|
|
2618
|
-
<ol class="ob-steps">
|
|
2619
|
-
<li>
|
|
2620
|
-
<span class="ob-num">1</span>
|
|
2621
|
-
<div>
|
|
2622
|
-
<div class="ob-step-title">Pick an agent — or let codehere pick</div>
|
|
2623
|
-
<div class="ob-step-body">Auto-pick is the default and works for most tasks. Specialists (Claude Code, Codex, Aider, Gemini, OpenCode) are below.</div>
|
|
2624
|
-
</div>
|
|
2625
|
-
</li>
|
|
2626
|
-
<li>
|
|
2627
|
-
<span class="ob-num">2</span>
|
|
2628
|
-
<div>
|
|
2629
|
-
<div class="ob-step-title">Type what needs doing</div>
|
|
2630
|
-
<div class="ob-step-body">Plain English. Try: "Show me what this repo does in 3 bullets" or "Run the tests and tell me what failed."</div>
|
|
2631
|
-
</div>
|
|
2632
|
-
</li>
|
|
2633
|
-
<li>
|
|
2634
|
-
<span class="ob-num">3</span>
|
|
2635
|
-
<div>
|
|
2636
|
-
<div class="ob-step-title">Watch the Final Report</div>
|
|
2637
|
-
<div class="ob-step-body">When the agent finishes, codehere generates a plain-English summary of what changed, what was tested, and what to look at next.</div>
|
|
2638
|
-
</div>
|
|
2639
|
-
</li>
|
|
2640
|
-
</ol>
|
|
2641
|
-
</div>
|
|
2642
|
-
|
|
2643
|
-
<div class="intake">
|
|
2644
|
-
<h1>Delegate coding tasks to any AI agent.</h1>
|
|
2645
|
-
<p class="sub">Type what needs doing — codehere picks the right agent, watches every action, and reports back.</p>
|
|
2646
|
-
<textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
|
|
2647
|
-
<!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
|
|
2648
|
-
dropped the cm-cwd pill — the cwd input below is the authoritative
|
|
2649
|
-
editable surface; duplicating it as a non-editable pill caused
|
|
2650
|
-
desync (pill stayed "—" while input showed the daemon-restored
|
|
2651
|
-
cwd). Folded the keyboard-shortcut hint inline-left so the prior
|
|
2652
|
-
second .intake-row could be deleted. -->
|
|
2653
|
-
<div class="composer-meta" id="composer-meta">
|
|
2654
|
-
<span class="cm-pill cm-agent" id="cm-agent"><b>Auto</b></span>
|
|
2655
|
-
<span class="cm-sep">·</span>
|
|
2656
|
-
<span class="cm-pill cm-budget" id="cm-budget" data-state="ok"><b>0%</b><span>used</span></span>
|
|
2657
|
-
<span class="cm-sep">·</span>
|
|
2658
|
-
<!-- v0.6 S3 slice 1 — today's estimated spend across every completed
|
|
2659
|
-
session in ~/.codehere/sessions.jsonl. Hidden when zero so a
|
|
2660
|
-
fresh-install dashboard isn't cluttered with "$0.00 today". Tooltip
|
|
2661
|
-
points to vendor dashboards as the authoritative number. -->
|
|
2662
|
-
<span class="cm-pill cm-spend" id="cm-spend" style="display:none" title="Estimated. Vendor dashboards are authoritative."><b>$0.00</b><span>today</span></span>
|
|
2663
|
-
<span class="cm-hint">⌘+Enter to start · ⌘+Shift+Enter to reply to last task</span>
|
|
2664
|
-
<span class="cm-spacer"></span>
|
|
2665
|
-
<span class="cm-pill cm-cost" id="cm-cost" style="display:none"></span>
|
|
2666
|
-
</div>
|
|
2667
|
-
<div class="starter-prompts" id="starter-prompts">
|
|
2668
|
-
<span class="sp-label">TRY</span>
|
|
2669
|
-
<button type="button" data-prompt="Read the README and summarize what this project is in 3 short bullets.">Summarize this repo</button>
|
|
2670
|
-
<button type="button" data-prompt="List the top-level files and give me a one-line note on what each is for.">Map the architecture</button>
|
|
2671
|
-
<button type="button" data-prompt="Run the tests and tell me what (if anything) is failing.">Run the tests</button>
|
|
2672
|
-
</div>
|
|
2673
|
-
<div class="intake-row">
|
|
2674
|
-
<select id="agent" title="codehere picks the right tool unless you override"><option value="">Auto-pick</option></select>
|
|
2675
|
-
<input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
|
|
2676
|
-
<button class="continue-thread" id="continue-thread" type="button" disabled title="Send as a reply to the most recent task in this folder (Slack-shape spike)">Continue thread</button>
|
|
2677
|
-
<button class="spawn" id="spawn">Assign new</button>
|
|
2678
|
-
</div>
|
|
2679
|
-
<div class="setup-panel" id="setup-panel" data-state=""></div>
|
|
2680
|
-
</div>
|
|
2681
|
-
|
|
2682
|
-
<!-- Apr 29 — UX overhaul: readiness banner + stats demoted below intake.
|
|
2683
|
-
Single muted line of stats replaces the previous two stacked rows.
|
|
2684
|
-
Goal: textarea is the focal point above the fold; chrome subordinates. -->
|
|
2685
|
-
<div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
|
|
2686
|
-
|
|
2687
|
-
<!-- v0.6.x P0 — failover offer banner. Surfaces when an adapter hits
|
|
2688
|
-
rate-limit / quota so the user can manually switch agents instead
|
|
2689
|
-
of waiting for auto-failover. Hidden by default; shown via JS when
|
|
2690
|
-
offers arrive from /api/failover-offers. -->
|
|
2691
|
-
<div class="failover-banner" id="failover-banner" style="display:none"></div>
|
|
2692
|
-
|
|
2693
|
-
<!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
|
|
2694
|
-
The prior 4-stat row mixed two definitions of "agents" (the AGENTS
|
|
2695
|
-
right-pane shows installed-and-ready agents; "agents on machine" stat
|
|
2696
|
-
showed F1d-detected running processes — empty in practice) producing
|
|
2697
|
-
a contradiction visible to users (e.g. "0 agents on machine" while the
|
|
2698
|
-
right pane shows 6). Plus "blocked" was almost always 0 and "avg trust
|
|
2699
|
-
100" had no signal. Per Open-Agents production-grade reference and
|
|
2700
|
-
claim_user_persona (HIGH non-technical-first): subtract dashboard noise.
|
|
2701
|
-
Keep only the one stat that varies meaningfully — actions today —
|
|
2702
|
-
rendered as a quiet muted line. The other IDs stay in the DOM (hidden)
|
|
2703
|
-
so existing setText calls in updateBanner don't NPE. -->
|
|
2704
|
-
<div class="stats-line empty" id="audit-summary" style="position:relative;z-index:1">
|
|
2705
|
-
<span><b id="as-actions">0</b> actions today</span>
|
|
2706
|
-
</div>
|
|
2707
|
-
|
|
2708
|
-
<!-- UX-simplification iter 3 (May 7): dropped the .glow-line peach
|
|
2709
|
-
divider here. The .section-label "Sessions" below already provides
|
|
2710
|
-
visual separation; an extra 1px gradient line was decoration not
|
|
2711
|
-
information. -->
|
|
2712
|
-
<div class="section-label" style="position:relative;z-index:1">Sessions</div>
|
|
2713
|
-
<div id="sessions" style="position:relative;z-index:1"></div>
|
|
2714
|
-
<div class="sessions-actions" id="sessions-actions">
|
|
2715
|
-
<button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
|
|
2716
|
-
<button class="stop-all-sessions" id="stop-all-sessions" type="button" style="display:none">Stop all running</button>
|
|
2717
|
-
</div>
|
|
2718
|
-
<div id="empty">
|
|
2719
|
-
<div class="pill">No sessions yet. Assign one above.</div>
|
|
2720
|
-
</div>
|
|
2721
|
-
|
|
2722
|
-
</section>
|
|
2723
|
-
|
|
2724
|
-
<aside class="members-pane">
|
|
2725
|
-
<div class="pane-head"><span>Agents</span><span class="pane-count" id="members-count">0</span></div>
|
|
2726
|
-
<div id="members-list">
|
|
2727
|
-
<div class="pane-empty">Loading agents…</div>
|
|
2728
|
-
</div>
|
|
2729
|
-
</aside>
|
|
2730
|
-
|
|
2731
|
-
</div>
|
|
2732
|
-
|
|
2733
|
-
<script>
|
|
2734
|
-
(function () {
|
|
2735
|
-
const promptEl = document.getElementById('prompt');
|
|
2736
|
-
const spawnBtn = document.getElementById('spawn');
|
|
2737
|
-
const agentSelect = document.getElementById('agent');
|
|
2738
|
-
const cwdInput = document.getElementById('cwd');
|
|
2739
|
-
const sessionsEl = document.getElementById('sessions');
|
|
2740
|
-
const emptyEl = document.getElementById('empty');
|
|
2741
|
-
const sessions = new Map(); // sessionId → { card, logEl, unsubscribeSse }
|
|
2742
|
-
|
|
2743
|
-
// v0.6 S2 — first-time onboarding card. Show ONLY when:
|
|
2744
|
-
// (a) localStorage flag codehere.onboarded is unset, AND
|
|
2745
|
-
// (b) no sessions yet (empty-state).
|
|
2746
|
-
// Hide when user dismisses (sets flag) OR when first session attaches.
|
|
2747
|
-
var obCard = document.getElementById('onboarding-card');
|
|
2748
|
-
var obClose = document.getElementById('ob-close');
|
|
2749
|
-
var ONBOARDED_KEY = 'codehere.onboarded';
|
|
2750
|
-
function isOnboarded() {
|
|
2751
|
-
try { return window.localStorage.getItem(ONBOARDED_KEY) === '1'; }
|
|
2752
|
-
catch (_) { return false; }
|
|
2753
|
-
}
|
|
2754
|
-
function markOnboarded() {
|
|
2755
|
-
try { window.localStorage.setItem(ONBOARDED_KEY, '1'); }
|
|
2756
|
-
catch (_) { /* private mode: persist for the session via the var below */ }
|
|
2757
|
-
}
|
|
2758
|
-
function maybeShowOnboarding() {
|
|
2759
|
-
if (!obCard) return;
|
|
2760
|
-
if (isOnboarded()) { obCard.style.display = 'none'; return; }
|
|
2761
|
-
obCard.style.display = '';
|
|
2762
|
-
}
|
|
2763
|
-
function dismissOnboarding() {
|
|
2764
|
-
if (!obCard) return;
|
|
2765
|
-
obCard.style.display = 'none';
|
|
2766
|
-
markOnboarded();
|
|
2767
|
-
}
|
|
2768
|
-
if (obClose) obClose.addEventListener('click', dismissOnboarding);
|
|
2769
|
-
// Render decision runs immediately + after first sessions fetch
|
|
2770
|
-
// (which sets sessions Map). Hide if any sessions already exist
|
|
2771
|
-
// (returning user — onboarding doesn't help).
|
|
2772
|
-
maybeShowOnboarding();
|
|
2773
|
-
|
|
2774
|
-
// Pattern 5 — composer meta line wiring. Live-updates: agent label,
|
|
2775
|
-
// context-budget %, cost preview. Bound below to the input surfaces
|
|
2776
|
-
// (prompt textarea, agent select). UX-simplification iter (May 7):
|
|
2777
|
-
// dropped cm-cwd pill — cwd input below is the authoritative surface.
|
|
2778
|
-
const cmAgentEl = document.getElementById('cm-agent');
|
|
2779
|
-
const cmBudgetEl = document.getElementById('cm-budget');
|
|
2780
|
-
const cmCostEl = document.getElementById('cm-cost');
|
|
2781
|
-
// v0.6 S3 slice 1 — today's spend chip. Lives next to the context-budget
|
|
2782
|
-
// chip; hidden until the first non-zero spend lands so a fresh-install
|
|
2783
|
-
// dashboard isn't cluttered. Polled, not pushed (events would need
|
|
2784
|
-
// server-side fanout).
|
|
2785
|
-
const cmSpendEl = document.getElementById('cm-spend');
|
|
2786
|
-
function refreshSpendToday() {
|
|
2787
|
-
if (!cmSpendEl) return;
|
|
2788
|
-
fetch('/api/spend/today')
|
|
2789
|
-
.then(function(r) { return r.ok ? r.json() : null; })
|
|
2790
|
-
.then(function(d) {
|
|
2791
|
-
if (!d || typeof d.totalUsd !== 'number') return;
|
|
2792
|
-
// Show the chip when EITHER spend > 0 OR a budget is set (so the
|
|
2793
|
-
// user sees "$0.00 / $5.00" right after \`codehere budget set 5\`).
|
|
2794
|
-
var budget = d.budget || {};
|
|
2795
|
-
var hasBudget = budget.dailyUsd !== null && typeof budget.dailyUsd === 'number';
|
|
2796
|
-
if (d.totalUsd <= 0 && d.sessionCount === 0 && !hasBudget) {
|
|
2797
|
-
cmSpendEl.style.display = 'none';
|
|
2798
|
-
return;
|
|
2799
|
-
}
|
|
2800
|
-
cmSpendEl.style.display = 'inline-flex';
|
|
2801
|
-
var b = cmSpendEl.querySelector('b');
|
|
2802
|
-
if (b) {
|
|
2803
|
-
var dollars = d.totalUsd;
|
|
2804
|
-
var spendStr = dollars >= 0.01 ? '$' + dollars.toFixed(2) : '$' + dollars.toFixed(3);
|
|
2805
|
-
// When a budget is set, render "$X.XX / $Y" so the ceiling is visible.
|
|
2806
|
-
b.textContent = hasBudget
|
|
2807
|
-
? spendStr + ' / $' + budget.dailyUsd.toFixed(2)
|
|
2808
|
-
: spendStr;
|
|
2809
|
-
}
|
|
2810
|
-
// Color state — warn at warnAtPct, block when over.
|
|
2811
|
-
var state = 'ok';
|
|
2812
|
-
if (hasBudget) {
|
|
2813
|
-
if (budget.over) state = 'block';
|
|
2814
|
-
else if (typeof budget.pctUsed === 'number' && budget.pctUsed >= (budget.warnAtPct || 70)) state = 'warn';
|
|
2815
|
-
}
|
|
2816
|
-
cmSpendEl.dataset.state = state;
|
|
2817
|
-
// Tooltip: per-agent breakdown + budget context if set.
|
|
2818
|
-
var lines = ['Estimated spend today across ' + d.sessionCount + ' completed session' + (d.sessionCount === 1 ? '' : 's') + ':'];
|
|
2819
|
-
var keys = Object.keys(d.byAgent || {});
|
|
2820
|
-
keys.sort(function(a, b) { return (d.byAgent[b].usd || 0) - (d.byAgent[a].usd || 0); });
|
|
2821
|
-
for (var i = 0; i < keys.length; i++) {
|
|
2822
|
-
var k = keys[i];
|
|
2823
|
-
var v = d.byAgent[k];
|
|
2824
|
-
lines.push(' ' + k + ': $' + (v.usd >= 0.01 ? v.usd.toFixed(2) : v.usd.toFixed(3)) + ' (' + v.sessions + 'x)');
|
|
2825
|
-
}
|
|
2826
|
-
if (hasBudget) {
|
|
2827
|
-
lines.push('');
|
|
2828
|
-
lines.push('Daily budget: $' + budget.dailyUsd.toFixed(2) + ' (' + (budget.pctUsed || 0) + '% used)');
|
|
2829
|
-
if (budget.over) {
|
|
2830
|
-
lines.push('OVER BUDGET — paid spawns will be refused. Use a free agent (gemini, opencode) or run: codehere budget set <higher-amount>');
|
|
2831
|
-
}
|
|
2832
|
-
}
|
|
2833
|
-
lines.push('Vendor dashboards are authoritative — these are tier-ceiling estimates.');
|
|
2834
|
-
cmSpendEl.title = lines.join('\\n');
|
|
2835
|
-
})
|
|
2836
|
-
.catch(function() { /* skip */ });
|
|
2837
|
-
}
|
|
2838
|
-
// Refresh on dashboard load + every 60s (sessions complete async, no point
|
|
2839
|
-
// polling faster than per-minute).
|
|
2840
|
-
refreshSpendToday();
|
|
2841
|
-
setInterval(refreshSpendToday, 60_000);
|
|
2842
|
-
|
|
2843
|
-
// Free-tier agents per feedback_adapters_must_have_free_tier.md. Cost
|
|
2844
|
-
// preview hides for these (the user pays nothing per call). Conductor
|
|
2845
|
-
// routes to one of the specialists, so its cost mirrors the picked one.
|
|
2846
|
-
const FREE_TIER_AGENTS = new Set(['gemini', 'opencode']);
|
|
2847
|
-
|
|
2848
|
-
// Per-agent rough cost estimate ($ per 1K input + 1K output tokens combined).
|
|
2849
|
-
// These are CEILINGS for ballpark ("~$0.0X likely") not precise — adapter
|
|
2850
|
-
// doesn't surface model selection, so the heuristic uses a balanced-tier
|
|
2851
|
-
// assumption. Better to under-promise; users care about scale-of-magnitude.
|
|
2852
|
-
const COST_PER_KTOK = {
|
|
2853
|
-
'claude-code': 0.018, // sonnet-tier balanced
|
|
2854
|
-
'codex': 0.020, // gpt-5-codex balanced
|
|
2855
|
-
'aider': 0.018, // typically sonnet
|
|
2856
|
-
'conductor': 0.018, // routes to claude-code by default
|
|
2857
|
-
};
|
|
2858
|
-
|
|
2859
|
-
function syncComposerMeta() {
|
|
2860
|
-
if (!cmAgentEl || !cmBudgetEl) return;
|
|
2861
|
-
// Resolve agent label. Empty value = Conductor-Auto routing.
|
|
2862
|
-
var pickedId = (agentSelect && agentSelect.value) || '';
|
|
2863
|
-
var label = pickedId
|
|
2864
|
-
? agentLabel(pickedId)
|
|
2865
|
-
: 'Auto';
|
|
2866
|
-
cmAgentEl.querySelector('b').textContent = label;
|
|
2867
|
-
// Context budget — char-count / 800K (≈ 200K-token Sonnet budget).
|
|
2868
|
-
// Cap at 100. --warn at 70, --block at 90 per spec.
|
|
2869
|
-
var chars = (promptEl && promptEl.value && promptEl.value.length) || 0;
|
|
2870
|
-
var pct = Math.min(100, Math.round((chars / 8000)));
|
|
2871
|
-
var budgetState = pct >= 90 ? 'block' : pct >= 70 ? 'warn' : 'ok';
|
|
2872
|
-
cmBudgetEl.dataset.state = budgetState;
|
|
2873
|
-
cmBudgetEl.querySelector('b').textContent = pct + '%';
|
|
2874
|
-
// Cost preview (paid agents only). Auto resolves to the conductor, so
|
|
2875
|
-
// pick the cheapest balanced-tier estimate when no agent is set.
|
|
2876
|
-
if (!cmCostEl) return;
|
|
2877
|
-
var effective = pickedId || 'conductor';
|
|
2878
|
-
if (FREE_TIER_AGENTS.has(effective)) {
|
|
2879
|
-
cmCostEl.style.display = 'inline-flex';
|
|
2880
|
-
cmCostEl.className = 'cm-pill cm-cost free';
|
|
2881
|
-
cmCostEl.innerHTML = '<b>free tier</b>';
|
|
2882
|
-
cmCostEl.title = effective + ' has a free tier — no per-call billing';
|
|
2883
|
-
} else if (COST_PER_KTOK[effective] && chars > 20) {
|
|
2884
|
-
// Rough heuristic: input tokens ≈ chars/4, output ~2× input for code
|
|
2885
|
-
// tasks; clamp to a sensible floor. Show 2 decimals when > 0.01,
|
|
2886
|
-
// 3 decimals otherwise so 4¢ doesn't render as "$0.04".
|
|
2887
|
-
var inputTok = Math.ceil(chars / 4);
|
|
2888
|
-
var outputTok = Math.max(500, Math.min(3000, inputTok * 2));
|
|
2889
|
-
var totalK = (inputTok + outputTok) / 1000;
|
|
2890
|
-
var dollars = totalK * COST_PER_KTOK[effective];
|
|
2891
|
-
var dollarsStr = dollars >= 0.01
|
|
2892
|
-
? '~$' + (Math.round(dollars * 100) / 100).toFixed(2)
|
|
2893
|
-
: '~$' + (Math.round(dollars * 1000) / 1000).toFixed(3);
|
|
2894
|
-
cmCostEl.style.display = 'inline-flex';
|
|
2895
|
-
cmCostEl.className = 'cm-pill cm-cost paid';
|
|
2896
|
-
cmCostEl.innerHTML = '<b>' + dollarsStr + '</b><span> est.</span>';
|
|
2897
|
-
cmCostEl.title = 'Estimate: ' + inputTok + ' input + ~' + outputTok + ' output tokens at balanced-tier rates';
|
|
2898
|
-
} else {
|
|
2899
|
-
cmCostEl.style.display = 'none';
|
|
2900
|
-
}
|
|
2901
|
-
}
|
|
2902
|
-
if (promptEl) promptEl.addEventListener('input', syncComposerMeta);
|
|
2903
|
-
if (agentSelect) agentSelect.addEventListener('change', syncComposerMeta);
|
|
2904
|
-
// Initial paint.
|
|
2905
|
-
syncComposerMeta();
|
|
2906
|
-
|
|
2907
|
-
// E3 · Populate agent dropdown from per-agent readiness.
|
|
2908
|
-
// Setup-aware: shows every registered agent with a state glyph (not just
|
|
2909
|
-
// installed ones). Selecting a non-ready agent unfolds the setup panel with
|
|
2910
|
-
// install/auth hints + swap suggestions.
|
|
2911
|
-
let agentReadiness = []; // cached copy of the readiness response
|
|
2912
|
-
const setupPanel = document.getElementById('setup-panel');
|
|
2913
|
-
|
|
2914
|
-
const STATE_GLYPH = {
|
|
2915
|
-
ready: '✓',
|
|
2916
|
-
billed_key_required: '⚠',
|
|
2917
|
-
needs_auth: '⚠',
|
|
2918
|
-
needs_install: '⤓',
|
|
2919
|
-
};
|
|
2920
|
-
|
|
2921
|
-
// Apr 29: Conductor readiness state lives separately from the agents
|
|
2922
|
-
// array (the dropdown shows specialists only). When auto.state is
|
|
2923
|
-
// 'needs_install' (no specialists installed yet), we surface that on the
|
|
2924
|
-
// Auto option AND in updateSetupPanel so a fresh-install user gets the
|
|
2925
|
-
// setup prompt instead of a runtime spawn failure (codex Round 1 catch).
|
|
2926
|
-
let autoReadiness = null;
|
|
2927
|
-
|
|
2928
|
-
function renderAgentOptions(agents, auto) {
|
|
2929
|
-
agentReadiness = agents || [];
|
|
2930
|
-
autoReadiness = auto || null;
|
|
2931
|
-
if (agentReadiness.length === 0 && !autoReadiness) return;
|
|
2932
|
-
// Sort: ready first, then needs_auth/billed, then needs_install
|
|
2933
|
-
const weight = { ready: 0, billed_key_required: 1, needs_auth: 2, needs_install: 3 };
|
|
2934
|
-
const sorted = [...agentReadiness].sort((a, b) => (weight[a.state] ?? 99) - (weight[b.state] ?? 99));
|
|
2935
|
-
const autoState = autoReadiness ? autoReadiness.state : 'ready';
|
|
2936
|
-
const autoGlyph = autoState === 'ready' ? '⚡' : (STATE_GLYPH[autoState] || '⚠');
|
|
2937
|
-
const autoLabel = autoState === 'ready' ? 'Auto-pick (codehere picks for you)' : 'Auto-pick (install an agent first)';
|
|
2938
|
-
const autoOption = '<option value="" selected>' + autoGlyph + ' ' + autoLabel + '</option>';
|
|
2939
|
-
const specialistOptions = sorted.map(a => {
|
|
2940
|
-
// v0.6 S5 — adapter description surfaces as the option's hover title so
|
|
2941
|
-
// non-technical users get a plain-English "when to use me" hint without
|
|
2942
|
-
// having to read docs. Falls back to label-only when adapter has none.
|
|
2943
|
-
const titleAttr = a.description
|
|
2944
|
-
? ' title="' + escape(a.description) + '"'
|
|
2945
|
-
: '';
|
|
2946
|
-
return '<option value="' + escape(a.id) + '"' + titleAttr + '>' +
|
|
2947
|
-
(STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
|
|
2948
|
-
'</option>';
|
|
2949
|
-
}).join('');
|
|
2950
|
-
// M5 slice 2 — fan-out option. Sends the same prompt to every
|
|
2951
|
-
// installed specialist in parallel via /api/task/batch. Per
|
|
2952
|
-
// claim_codehere_shape v5 + Decision 22 (every coding agent a
|
|
2953
|
-
// first-class member), cross-check spawning is the v1.0 value
|
|
2954
|
-
// prop. Hidden when fewer than 2 specialists are ready (no
|
|
2955
|
-
// parallel value with one). Capped at 5 by the batch endpoint.
|
|
2956
|
-
const readyCount = sorted.filter(a => a.state === 'ready').length;
|
|
2957
|
-
const fanOutOption = readyCount >= 2
|
|
2958
|
-
? '<option value="*">⚡⚡ Send to every installed agent (' + readyCount + ' in parallel)</option>'
|
|
2959
|
-
: '';
|
|
2960
|
-
agentSelect.innerHTML = autoOption + fanOutOption + specialistOptions;
|
|
2961
|
-
// Trigger the panel render for whatever is selected first
|
|
2962
|
-
updateSetupPanel();
|
|
2963
|
-
}
|
|
2964
|
-
|
|
2965
|
-
function updateSetupPanel() {
|
|
2966
|
-
const id = agentSelect.value;
|
|
2967
|
-
// Apr 29: empty value = Auto option = use conductor's readiness (which
|
|
2968
|
-
// says "install at least one specialist" when none are installed).
|
|
2969
|
-
const agent = id === '' ? autoReadiness : agentReadiness.find(a => a.id === id);
|
|
2970
|
-
if (!agent || agent.state === 'ready') {
|
|
2971
|
-
setupPanel.style.display = 'none';
|
|
2972
|
-
setupPanel.removeAttribute('data-state');
|
|
2973
|
-
return;
|
|
2974
|
-
}
|
|
2975
|
-
setupPanel.dataset.state = agent.state;
|
|
2976
|
-
setupPanel.style.display = 'block';
|
|
2977
|
-
|
|
2978
|
-
let title = '', body = '';
|
|
2979
|
-
let installBtnHtml = '';
|
|
2980
|
-
if (agent.state === 'needs_install') {
|
|
2981
|
-
// Principle #10: codehere does the install, it doesn't print shell commands.
|
|
2982
|
-
// Show a button. Hide the raw command behind a "Details" toggle for users
|
|
2983
|
-
// who want to know what's being run.
|
|
2984
|
-
title = escape(agent.label) + ' isn\\'t installed yet';
|
|
2985
|
-
body = '<div>Click install and codehere will set it up for you — nothing to type.</div>';
|
|
2986
|
-
const hint = agent.installHint || {};
|
|
2987
|
-
if (hint.command) {
|
|
2988
|
-
installBtnHtml =
|
|
2989
|
-
'<div class="install-row">' +
|
|
2990
|
-
'<button type="button" class="install" data-agent="' + escape(agent.id) + '">Install ' + escape(agent.label) + '</button>' +
|
|
2991
|
-
'<button type="button" class="install-details-toggle" data-toggle="details">Details</button>' +
|
|
2992
|
-
'</div>' +
|
|
2993
|
-
'<div class="install-output" data-role="details" style="display:none">' +
|
|
2994
|
-
'<div style="color:var(--fg-50);margin-bottom:6px">We\\'ll run: <span style="color:var(--fg)">' + escape(hint.command) + '</span></div>' +
|
|
2995
|
-
'</div>';
|
|
2996
|
-
} else {
|
|
2997
|
-
body += '<div style="margin-top:6px;color:var(--fg-50)">This agent doesn\\'t ship with an automatic installer yet. You can still swap to a ready agent below.</div>';
|
|
2998
|
-
}
|
|
2999
|
-
} else if (agent.state === 'needs_auth') {
|
|
3000
|
-
title = escape(agent.label) + ' needs to be connected to an account';
|
|
3001
|
-
const hint = agent.authHint || {};
|
|
3002
|
-
body = (hint.detail ? '<div>' + escape(hint.detail) + '</div>' : '');
|
|
3003
|
-
if (hint.url) {
|
|
3004
|
-
body += '<div style="margin-top:6px"><a href="' + escape(hint.url) + '" target="_blank" rel="noreferrer">Open the sign-in page →</a></div>';
|
|
3005
|
-
}
|
|
3006
|
-
} else if (agent.state === 'billed_key_required') {
|
|
3007
|
-
title = escape(agent.label) + ' needs a paid API key';
|
|
3008
|
-
body = '<div>This agent doesn\\'t work with a Claude Code subscription — it needs a separate paid key from Anthropic or OpenAI. Using one of the ready agents below is usually the faster path.</div>';
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
const readyAgents = agentReadiness.filter(a => a.state === 'ready');
|
|
3012
|
-
const swapHtml = readyAgents.length > 0
|
|
3013
|
-
? '<div class="swap">' +
|
|
3014
|
-
'<span class="swap-label">Use instead:</span>' +
|
|
3015
|
-
readyAgents.map(a => '<button type="button" data-swap="' + escape(a.id) + '">' + escape(a.label) + '</button>').join('') +
|
|
3016
|
-
'</div>'
|
|
3017
|
-
: '';
|
|
3018
|
-
|
|
3019
|
-
setupPanel.innerHTML =
|
|
3020
|
-
'<div class="sp-title">' + title + '</div>' +
|
|
3021
|
-
body + installBtnHtml + swapHtml;
|
|
3022
|
-
}
|
|
3023
|
-
|
|
3024
|
-
// F2 · Install button handler — streams output in place, flips readiness on done.
|
|
3025
|
-
function runInstall(agentId, btnEl, outputEl) {
|
|
3026
|
-
btnEl.classList.add('running');
|
|
3027
|
-
btnEl.disabled = true;
|
|
3028
|
-
btnEl.textContent = 'Installing…';
|
|
3029
|
-
outputEl.classList.add('show');
|
|
3030
|
-
outputEl.textContent = '';
|
|
3031
|
-
let hadErr = false;
|
|
3032
|
-
|
|
3033
|
-
const res = fetch('/api/agents/' + encodeURIComponent(agentId) + '/install', { method: 'POST' });
|
|
3034
|
-
// The server responds with SSE. We read the body as a stream.
|
|
3035
|
-
res.then(r => {
|
|
3036
|
-
if (!r.ok) throw new Error('install request failed: ' + r.status);
|
|
3037
|
-
const reader = r.body.getReader();
|
|
3038
|
-
const decoder = new TextDecoder();
|
|
3039
|
-
let buf = '';
|
|
3040
|
-
const pump = () => reader.read().then(({ value, done }) => {
|
|
3041
|
-
if (done) return;
|
|
3042
|
-
buf += decoder.decode(value, { stream: true });
|
|
3043
|
-
let idx;
|
|
3044
|
-
while ((idx = buf.indexOf('\\n\\n')) >= 0) {
|
|
3045
|
-
const chunk = buf.slice(0, idx).trim();
|
|
3046
|
-
buf = buf.slice(idx + 2);
|
|
3047
|
-
if (!chunk.startsWith('data:')) continue;
|
|
3048
|
-
const json = chunk.slice(5).trim();
|
|
3049
|
-
if (!json) continue;
|
|
3050
|
-
try {
|
|
3051
|
-
const evt = JSON.parse(json);
|
|
3052
|
-
if (evt.type === 'out' || evt.type === 'err') {
|
|
3053
|
-
if (evt.type === 'err') hadErr = true;
|
|
3054
|
-
outputEl.textContent += evt.data || '';
|
|
3055
|
-
outputEl.scrollTop = outputEl.scrollHeight;
|
|
3056
|
-
} else if (evt.type === 'done') {
|
|
3057
|
-
btnEl.classList.remove('running');
|
|
3058
|
-
if (evt.code === 0) {
|
|
3059
|
-
btnEl.classList.add('done');
|
|
3060
|
-
btnEl.textContent = '✓ Installed';
|
|
3061
|
-
// Re-fetch readiness so the dropdown glyph updates.
|
|
3062
|
-
// Apr 29 codex Round 2: pass d.auto too — otherwise the Auto
|
|
3063
|
-
// option's readiness gets stale after install-success refresh
|
|
3064
|
-
// (the new shape needs both fields preserved on every render).
|
|
3065
|
-
fetch('/api/readiness/agents').then(r => r.json()).then(d => {
|
|
3066
|
-
renderAgentOptions(d.agents || [], d.auto);
|
|
3067
|
-
// Select the now-installed agent so user can run the task immediately
|
|
3068
|
-
if ([...agentSelect.options].some(o => o.value === agentId)) {
|
|
3069
|
-
agentSelect.value = agentId;
|
|
3070
|
-
updateSetupPanel();
|
|
3071
|
-
}
|
|
3072
|
-
});
|
|
3073
|
-
} else {
|
|
3074
|
-
btnEl.classList.add('failed');
|
|
3075
|
-
btnEl.textContent = '✗ Install failed';
|
|
3076
|
-
if (hadErr) outputEl.classList.add('has-err');
|
|
3077
|
-
}
|
|
3078
|
-
} else if (evt.type === 'error') {
|
|
3079
|
-
btnEl.classList.remove('running');
|
|
3080
|
-
btnEl.classList.add('failed');
|
|
3081
|
-
btnEl.textContent = '✗ Couldn\\'t run';
|
|
3082
|
-
outputEl.textContent += '\\n' + (evt.data || '');
|
|
3083
|
-
outputEl.classList.add('has-err');
|
|
3084
|
-
}
|
|
3085
|
-
} catch { /* skip malformed */ }
|
|
3086
|
-
}
|
|
3087
|
-
return pump();
|
|
3088
|
-
});
|
|
3089
|
-
return pump();
|
|
3090
|
-
}).catch(err => {
|
|
3091
|
-
btnEl.classList.remove('running');
|
|
3092
|
-
btnEl.classList.add('failed');
|
|
3093
|
-
btnEl.textContent = '✗ Couldn\\'t start';
|
|
3094
|
-
outputEl.textContent = String(err && err.message || err);
|
|
3095
|
-
outputEl.classList.add('has-err');
|
|
3096
|
-
});
|
|
3097
|
-
}
|
|
3098
|
-
|
|
3099
|
-
setupPanel.addEventListener('click', (ev) => {
|
|
3100
|
-
const t = ev.target;
|
|
3101
|
-
if (!t) return;
|
|
3102
|
-
const swapBtn = t.closest('button[data-swap]');
|
|
3103
|
-
if (swapBtn) {
|
|
3104
|
-
const id = swapBtn.getAttribute('data-swap');
|
|
3105
|
-
if (!id) return;
|
|
3106
|
-
agentSelect.value = id;
|
|
3107
|
-
updateSetupPanel();
|
|
3108
|
-
return;
|
|
3109
|
-
}
|
|
3110
|
-
const installBtn = t.closest('button.install[data-agent]');
|
|
3111
|
-
if (installBtn && !installBtn.disabled) {
|
|
3112
|
-
const id = installBtn.getAttribute('data-agent');
|
|
3113
|
-
const output = setupPanel.querySelector('.install-output[data-role="details"]');
|
|
3114
|
-
if (id && output) runInstall(id, installBtn, output);
|
|
3115
|
-
return;
|
|
3116
|
-
}
|
|
3117
|
-
const toggle = t.closest('button[data-toggle="details"]');
|
|
3118
|
-
if (toggle) {
|
|
3119
|
-
const output = setupPanel.querySelector('.install-output[data-role="details"]');
|
|
3120
|
-
if (output) output.classList.toggle('show');
|
|
3121
|
-
return;
|
|
3122
|
-
}
|
|
3123
|
-
});
|
|
3124
|
-
agentSelect.addEventListener('change', updateSetupPanel);
|
|
3125
|
-
|
|
3126
|
-
// Apr 29 — Slack-shape: render the members pane (right) from the same
|
|
3127
|
-
// /api/readiness/agents response that populates the override dropdown.
|
|
3128
|
-
// Single fetch, two surfaces. A later release replaces this with live presence
|
|
3129
|
-
// (F1d-detected agents + spawned-by-codehere sessions joined by id).
|
|
3130
|
-
function renderMembersPane(agents, auto) {
|
|
3131
|
-
const list = document.getElementById('members-list');
|
|
3132
|
-
const count = document.getElementById('members-count');
|
|
3133
|
-
if (!list || !count) return;
|
|
3134
|
-
const all = [];
|
|
3135
|
-
if (auto) all.push({ ...auto, id: 'conductor', label: 'codehere · watches the others' });
|
|
3136
|
-
for (const a of (agents || [])) all.push(a);
|
|
3137
|
-
count.textContent = String(all.length);
|
|
3138
|
-
if (all.length === 0) { list.innerHTML = '<div class="pane-empty">No agents detected</div>'; return; }
|
|
3139
|
-
const presenceCls = (s) => s === 'ready' ? 'ready' : (s === 'needs_install' ? 'offline' : 'warn');
|
|
3140
|
-
const presenceGlyph = (s) => s === 'ready' ? '●' : (s === 'needs_install' ? '○' : '◐');
|
|
3141
|
-
list.innerHTML = all.map((a) => {
|
|
3142
|
-
const cls = presenceCls(a.state);
|
|
3143
|
-
const glyph = presenceGlyph(a.state);
|
|
3144
|
-
// Iter 12 — members are now clickable. Non-technical user expects
|
|
3145
|
-
// clicking an agent in the right pane to pick that agent for the
|
|
3146
|
-
// next task. Click handler below sets the agent <select> + scrolls
|
|
3147
|
-
// intake into view + focuses the prompt textarea.
|
|
3148
|
-
const stateTitle = a.state === 'ready' ? 'ready · click to select' :
|
|
3149
|
-
a.state === 'needs_install' ? 'needs install · click for setup' :
|
|
3150
|
-
a.state === 'needs_auth' ? 'needs auth · click for setup' :
|
|
3151
|
-
(a.state || 'unknown');
|
|
3152
|
-
return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
|
|
3153
|
-
'<span class="member-presence ' + cls + '">' + glyph + '</span>' +
|
|
3154
|
-
agentBadgeHTML(a.id) + ' ' +
|
|
3155
|
-
'<span class="member-label">' + escape(a.label || a.id) + '</span>' +
|
|
3156
|
-
'</div>';
|
|
3157
|
-
}).join('');
|
|
3158
|
-
// Wire click → select-this-agent. One delegated handler is cheaper than
|
|
3159
|
-
// attaching one per item, and survives re-renders without leak.
|
|
3160
|
-
list.onclick = (ev) => {
|
|
3161
|
-
const item = ev.target && ev.target.closest && ev.target.closest('.member');
|
|
3162
|
-
if (!item) return;
|
|
3163
|
-
const id = item.getAttribute('data-agent-id');
|
|
3164
|
-
if (!id) return;
|
|
3165
|
-
// Set the agent dropdown to this agent. Trigger 'change' so the
|
|
3166
|
-
// updateSetupPanel code (existing) fires and shows install/auth help
|
|
3167
|
-
// for needs_install / needs_auth states.
|
|
3168
|
-
if (agentSelect) {
|
|
3169
|
-
agentSelect.value = id;
|
|
3170
|
-
try { agentSelect.dispatchEvent(new Event('change')); } catch { /* old browsers */ }
|
|
3171
|
-
}
|
|
3172
|
-
// Scroll intake into view + focus the prompt textarea.
|
|
3173
|
-
try { promptEl && promptEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* noop */ }
|
|
3174
|
-
if (promptEl) promptEl.focus();
|
|
3175
|
-
};
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
|
-
fetch('/api/readiness/agents').then(r => r.json()).then(d => {
|
|
3179
|
-
renderAgentOptions(d.agents || [], d.auto);
|
|
3180
|
-
renderMembersPane(d.agents || [], d.auto);
|
|
3181
|
-
}).catch(() => { /* keep defaults */ });
|
|
3182
|
-
|
|
3183
|
-
// Working directory: persist user's pick, auto-fill with daemon cwd on first run.
|
|
3184
|
-
// Gate A1: strangers shouldn't have to type a path just to hit "Assign"; the
|
|
3185
|
-
// daemon already knows where it was launched and that's usually the right cwd.
|
|
3186
|
-
const savedCwd = localStorage.getItem('codehere:cwd');
|
|
3187
|
-
if (savedCwd) cwdInput.value = savedCwd;
|
|
3188
|
-
|
|
3189
|
-
// Track daemon cwd for active-channel highlight in the projects sidebar.
|
|
3190
|
-
// Refreshed on every /api/readiness call; multi-project sidebar reads it.
|
|
3191
|
-
let daemonCwdForActive = null;
|
|
3192
|
-
|
|
3193
|
-
fetch('/api/readiness').then(r => r.json()).then(r => {
|
|
3194
|
-
const daemonCwd = r && r.detected && r.detected.daemonCwd;
|
|
3195
|
-
if (daemonCwd) {
|
|
3196
|
-
cwdInput.placeholder = daemonCwd;
|
|
3197
|
-
if (!cwdInput.value.trim()) cwdInput.value = daemonCwd; // auto-fill when nothing saved
|
|
3198
|
-
daemonCwdForActive = daemonCwd;
|
|
3199
|
-
loadDashboardProjects(); // re-render sidebar now that we know which project is "active"
|
|
3200
|
-
}
|
|
3201
|
-
}).catch(() => { /* placeholder unchanged */ });
|
|
3202
|
-
|
|
3203
|
-
// Multi-project sidebar — bug A1 fix (May 5 2026). Pre-fix: sidebar showed
|
|
3204
|
-
// a single hardcoded "current" channel that was un-clickable; users hit it
|
|
3205
|
-
// expecting to switch projects. Post-fix: real /api/projects load, click
|
|
3206
|
-
// navigates to /threads?project=<slug> (where the actual thread surface
|
|
3207
|
-
// lives), pencil rename works inline, active state matches daemon cwd.
|
|
3208
|
-
function shortPathDash(p) {
|
|
3209
|
-
if (!p) return '';
|
|
3210
|
-
const home = (window.location && window.location.hostname) ? '~' : '~';
|
|
3211
|
-
if (p.length <= 32) return p;
|
|
3212
|
-
const parts = p.split('/').filter(Boolean);
|
|
3213
|
-
if (parts.length <= 2) return p;
|
|
3214
|
-
return '…/' + parts.slice(-2).join('/');
|
|
3215
|
-
}
|
|
3216
|
-
function escapeHtmlDash(s) {
|
|
3217
|
-
if (s == null) return '';
|
|
3218
|
-
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
3219
|
-
}
|
|
3220
|
-
function renderDashboardProjects(list) {
|
|
3221
|
-
const wrap = document.getElementById('channels-list');
|
|
3222
|
-
const countEl = document.getElementById('channels-count');
|
|
3223
|
-
if (!wrap) return;
|
|
3224
|
-
if (countEl) countEl.textContent = list.length;
|
|
3225
|
-
if (list.length === 0) {
|
|
3226
|
-
wrap.innerHTML = '<div class="pane-empty">No projects yet</div>';
|
|
3227
|
-
return;
|
|
3228
|
-
}
|
|
3229
|
-
wrap.innerHTML = list.map(p => {
|
|
3230
|
-
const isActive = (daemonCwdForActive && p.cwd === daemonCwdForActive) ? ' active' : '';
|
|
3231
|
-
const name = escapeHtmlDash(p.title || '(untitled)');
|
|
3232
|
-
const cwdHint = escapeHtmlDash(shortPathDash(p.cwd));
|
|
3233
|
-
// Pattern 4 — multi-agent presence stack. Up to 3 dots (most-recent
|
|
3234
|
-
// first, deduped). Empty span when no agents in the thread (older
|
|
3235
|
-
// pre-mirror projects). agentPresenceDotHTML lives in AGENT_BADGE_JS;
|
|
3236
|
-
// each dot is 6px, vendor-slot color, no letter.
|
|
3237
|
-
const agents = Array.isArray(p.recentAgents) ? p.recentAgents : (p.lastAgent ? [p.lastAgent] : []);
|
|
3238
|
-
const presenceStack = agents.length > 0
|
|
3239
|
-
? '<span class="presence-stack" title="Recent agents: ' + escapeHtmlDash(agents.join(', ')) + '">' +
|
|
3240
|
-
agents.map(a => agentPresenceDotHTML(a)).join('') +
|
|
3241
|
-
'</span>'
|
|
3242
|
-
: '<span class="presence-stack presence-stack-empty"></span>';
|
|
3243
|
-
return '<div class="channel' + isActive + '" data-slug="' + escapeHtmlDash(p.slug) + '" title="Open project thread">' +
|
|
3244
|
-
'<span class="channel-glyph">#</span>' +
|
|
3245
|
-
'<div style="flex:1;min-width:0">' +
|
|
3246
|
-
'<div class="channel-name">' + name + '</div>' +
|
|
3247
|
-
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
3248
|
-
'</div>' +
|
|
3249
|
-
presenceStack +
|
|
3250
|
-
'<button class="channel-rename" data-slug="' + escapeHtmlDash(p.slug) + '" title="Rename project" aria-label="Rename project"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg></button>' +
|
|
3251
|
-
'</div>';
|
|
3252
|
-
}).join('');
|
|
3253
|
-
wrap.querySelectorAll('.channel').forEach(el => {
|
|
3254
|
-
el.addEventListener('click', (ev) => {
|
|
3255
|
-
if (ev.target && ev.target.classList && ev.target.classList.contains('channel-rename')) return;
|
|
3256
|
-
const slug = el.getAttribute('data-slug');
|
|
3257
|
-
if (slug) window.location.href = '/threads?project=' + encodeURIComponent(slug);
|
|
3258
|
-
});
|
|
3259
|
-
});
|
|
3260
|
-
wrap.querySelectorAll('.channel-rename').forEach(btn => {
|
|
3261
|
-
btn.addEventListener('click', (ev) => {
|
|
3262
|
-
ev.stopPropagation();
|
|
3263
|
-
const slug = btn.getAttribute('data-slug');
|
|
3264
|
-
if (!slug) return;
|
|
3265
|
-
const channel = btn.closest('.channel');
|
|
3266
|
-
const project = list.find(p => p.slug === slug);
|
|
3267
|
-
const current = (project && project.title) || '';
|
|
3268
|
-
startInlineRename(channel, slug, current, () => loadDashboardProjects());
|
|
3269
|
-
});
|
|
3270
|
-
});
|
|
3271
|
-
}
|
|
3272
|
-
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
3273
|
-
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
3274
|
-
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
3275
|
-
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
3276
|
-
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
3277
|
-
if (!channelEl) return;
|
|
3278
|
-
const nameEl = channelEl.querySelector('.channel-name');
|
|
3279
|
-
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
3280
|
-
nameEl.dataset.editing = '1';
|
|
3281
|
-
const originalText = nameEl.textContent;
|
|
3282
|
-
const input = document.createElement('input');
|
|
3283
|
-
input.type = 'text';
|
|
3284
|
-
input.value = currentTitle;
|
|
3285
|
-
input.maxLength = 100;
|
|
3286
|
-
input.className = 'channel-rename-input';
|
|
3287
|
-
nameEl.textContent = '';
|
|
3288
|
-
nameEl.appendChild(input);
|
|
3289
|
-
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
3290
|
-
let cleanedUp = false;
|
|
3291
|
-
function cleanup(restoreText) {
|
|
3292
|
-
if (cleanedUp) return;
|
|
3293
|
-
cleanedUp = true;
|
|
3294
|
-
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
3295
|
-
nameEl.textContent = restoreText;
|
|
3296
|
-
delete nameEl.dataset.editing;
|
|
3297
|
-
}
|
|
3298
|
-
function commit() {
|
|
3299
|
-
if (cleanedUp) return;
|
|
3300
|
-
const trimmed = input.value.trim();
|
|
3301
|
-
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
3302
|
-
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
3303
|
-
method: 'POST',
|
|
3304
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3305
|
-
body: JSON.stringify({ title: trimmed }),
|
|
3306
|
-
})
|
|
3307
|
-
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
3308
|
-
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
3309
|
-
.catch(() => {
|
|
3310
|
-
input.classList.remove('error');
|
|
3311
|
-
// Force reflow so animation re-fires
|
|
3312
|
-
void input.offsetWidth;
|
|
3313
|
-
input.classList.add('error');
|
|
3314
|
-
});
|
|
3315
|
-
}
|
|
3316
|
-
input.addEventListener('keydown', (ev) => {
|
|
3317
|
-
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
3318
|
-
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
3319
|
-
});
|
|
3320
|
-
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
3321
|
-
}
|
|
3322
|
-
function loadDashboardProjects() {
|
|
3323
|
-
fetch('/api/projects')
|
|
3324
|
-
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
3325
|
-
.then(data => renderDashboardProjects(data.projects || []))
|
|
3326
|
-
.catch(() => { /* leave loading state */ });
|
|
3327
|
-
}
|
|
3328
|
-
loadDashboardProjects();
|
|
3329
|
-
cwdInput.addEventListener('change', () => {
|
|
3330
|
-
localStorage.setItem('codehere:cwd', cwdInput.value.trim());
|
|
3331
|
-
});
|
|
3332
|
-
|
|
3333
|
-
// Iter 13 — sidebar 'add project' button + handler removed (was iter 12 work).
|
|
3334
|
-
// The handler cleared cwd input + scrolled + focused, but the label promised
|
|
3335
|
-
// a new sidebar entry that we do not ship until v0.6.x multi-project work.
|
|
3336
|
-
// Mismatch caused real founder confusion in iter-13 tasting. Removed cleanly.
|
|
3337
|
-
|
|
3338
|
-
// Gate A1: starter-prompt chips prefill the textarea + focus it so the user
|
|
3339
|
-
// can edit or hit ⌘-Enter immediately. No scroll-jumping, no redirect.
|
|
3340
|
-
const starterPromptsEl = document.getElementById('starter-prompts');
|
|
3341
|
-
starterPromptsEl.addEventListener('click', (ev) => {
|
|
3342
|
-
const btn = ev.target && ev.target.closest('button[data-prompt]');
|
|
3343
|
-
if (!btn) return;
|
|
3344
|
-
promptEl.value = btn.getAttribute('data-prompt') || '';
|
|
3345
|
-
promptEl.focus();
|
|
3346
|
-
starterPromptsEl.classList.add('dim'); // user picked one; chips fade
|
|
3347
|
-
});
|
|
3348
|
-
|
|
3349
|
-
// Apr 29 — UX overhaul: TRY chips fade out once the user starts typing.
|
|
3350
|
-
// They serve as a discovery aid for first-visit users; a typing user has
|
|
3351
|
-
// already committed to a task and doesn't need the chips above the page.
|
|
3352
|
-
promptEl.addEventListener('input', () => {
|
|
3353
|
-
if (promptEl.value.length > 0) starterPromptsEl.classList.add('dim');
|
|
3354
|
-
else starterPromptsEl.classList.remove('dim');
|
|
3355
|
-
});
|
|
3356
|
-
|
|
3357
|
-
function escape(s) {
|
|
3358
|
-
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
3359
|
-
}
|
|
3360
|
-
|
|
3361
|
-
function now() {
|
|
3362
|
-
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
3363
|
-
}
|
|
3364
|
-
|
|
3365
|
-
// Render a stream-json event from claude -p --output-format=stream-json.
|
|
3366
|
-
// We've seen the real shapes now (v0.3.0-alpha.1 dogfood). Key rules:
|
|
3367
|
-
// - stream_event = token-level deltas. Skip entirely — they aggregate into
|
|
3368
|
-
// 'assistant' / 'user' messages which we render cleanly.
|
|
3369
|
-
// - system subtypes: init/status are noise (skip); hook_started is noise
|
|
3370
|
-
// (skip — we render the paired hook_response for outcome). hook_response
|
|
3371
|
-
// shows name + allow/block so the user sees the safety layer.
|
|
3372
|
-
// - assistant message.content: mix of text + tool_use blocks. Render each.
|
|
3373
|
-
// - user message.content: tool_result blocks. Render condensed.
|
|
3374
|
-
// - result: final summary with duration + outcome.
|
|
3375
|
-
// - exit/error/stderr: meta from our own wrapper.
|
|
3376
|
-
function renderEvent(logEl, event) {
|
|
3377
|
-
const type = String(event.type || '');
|
|
3378
|
-
const data = event.data ?? {};
|
|
3379
|
-
|
|
3380
|
-
// ── Silent types (reduce noise) ──
|
|
3381
|
-
if (type === 'stream_event') return;
|
|
3382
|
-
if (type === 'system' && (data.subtype === 'init' || data.subtype === 'status' || data.subtype === 'hook_started')) return;
|
|
3383
|
-
if (type === 'stdout' && !String(data.line || '').trim()) return;
|
|
3384
|
-
// Codex internal lifecycle — not useful to show per-event
|
|
3385
|
-
if (type === 'thread.started' || type === 'turn.started') return;
|
|
3386
|
-
|
|
3387
|
-
// Rendered lines
|
|
3388
|
-
const lines = []; // { cls, label, body }
|
|
3389
|
-
|
|
3390
|
-
if (type === 'assistant' && data.message && Array.isArray(data.message.content)) {
|
|
3391
|
-
for (const block of data.message.content) {
|
|
3392
|
-
if (block.type === 'text') {
|
|
3393
|
-
const t = String(block.text || '').trim();
|
|
3394
|
-
if (t) lines.push({ cls: 'claude', label: 'claude', body: t });
|
|
3395
|
-
} else if (block.type === 'tool_use') {
|
|
3396
|
-
const name = String(block.name || 'tool');
|
|
3397
|
-
const input = block.input && typeof block.input === 'object' ? JSON.stringify(block.input) : '';
|
|
3398
|
-
const preview = input.length > 180 ? input.slice(0, 180) + '…' : input;
|
|
3399
|
-
lines.push({ cls: 'tool', label: name, body: preview });
|
|
3400
|
-
} else if (block.type === 'thinking') {
|
|
3401
|
-
// Skip thinking — too verbose for the log view
|
|
3402
|
-
}
|
|
3403
|
-
}
|
|
3404
|
-
} else if (type === 'user' && data.message && Array.isArray(data.message.content)) {
|
|
3405
|
-
for (const block of data.message.content) {
|
|
3406
|
-
if (block.type === 'tool_result') {
|
|
3407
|
-
const raw = Array.isArray(block.content)
|
|
3408
|
-
? block.content.map(c => c.text || '').join('')
|
|
3409
|
-
: String(block.content || '');
|
|
3410
|
-
const t = raw.replace(/\\s+/g, ' ').trim();
|
|
3411
|
-
if (t) {
|
|
3412
|
-
const preview = t.length > 220 ? t.slice(0, 220) + '…' : t;
|
|
3413
|
-
lines.push({ cls: 'result', label: block.is_error ? 'error' : 'result', body: preview });
|
|
3414
|
-
}
|
|
3415
|
-
}
|
|
3416
|
-
}
|
|
3417
|
-
} else if (type === 'system' && data.subtype === 'hook_response') {
|
|
3418
|
-
const name = String(data.hook_name || 'hook');
|
|
3419
|
-
const exitCode = Number(data.exit_code ?? 0);
|
|
3420
|
-
const blocked = exitCode !== 0;
|
|
3421
|
-
const stderr = String(data.stderr || '').trim();
|
|
3422
|
-
const body = blocked ? (stderr || 'blocked') : 'ok';
|
|
3423
|
-
lines.push({ cls: 'hook' + (blocked ? ' blocked' : ''), label: name, body });
|
|
3424
|
-
} else if (type === 'result') {
|
|
3425
|
-
// Claude Code --output-format=stream-json final result
|
|
3426
|
-
const dur = Math.round(Number(data.duration_ms || 0) / 1000);
|
|
3427
|
-
const turns = Number(data.num_turns || 0);
|
|
3428
|
-
const summary = String(data.result || '').slice(0, 220);
|
|
3429
|
-
lines.push({ cls: 'done', label: data.subtype || 'done', body: turns + ' turns · ' + dur + 's · ' + summary });
|
|
3430
|
-
} else if (type === 'item.started') {
|
|
3431
|
-
// Codex fires item.started immediately; item.completed follows with results.
|
|
3432
|
-
// Silent to avoid doubling every tool call in the feed.
|
|
3433
|
-
return;
|
|
3434
|
-
} else if (type === 'item.completed') {
|
|
3435
|
-
// Codex event: { item: { type, ... } }. Observed item.types from live dogfood:
|
|
3436
|
-
// agent_message → text
|
|
3437
|
-
// command_execution → command (string), aggregated_output, exit_code, status
|
|
3438
|
-
// (+future: tool_call / local_shell_call / etc — defensive branches kept)
|
|
3439
|
-
const item = data.item && typeof data.item === 'object' ? data.item : {};
|
|
3440
|
-
const itemType = String(item.type || 'item');
|
|
3441
|
-
|
|
3442
|
-
if (itemType === 'agent_message' && item.text) {
|
|
3443
|
-
const t = String(item.text).trim();
|
|
3444
|
-
if (t) lines.push({ cls: 'claude', label: 'codex', body: t.slice(0, 280) });
|
|
3445
|
-
} else if (itemType === 'command_execution') {
|
|
3446
|
-
const cmd = String(item.command || '').replace(/\\n/g, ' ').trim();
|
|
3447
|
-
const cmdShort = cmd.length > 140 ? cmd.slice(0, 140) + '…' : cmd;
|
|
3448
|
-
lines.push({ cls: 'tool', label: 'shell', body: cmdShort });
|
|
3449
|
-
const output = String(item.aggregated_output || '').trim();
|
|
3450
|
-
if (output) {
|
|
3451
|
-
const outShort = output.length > 200 ? output.slice(0, 200) + '…' : output;
|
|
3452
|
-
lines.push({
|
|
3453
|
-
cls: item.exit_code === 0 || item.status === 'completed' ? 'result' : 'error',
|
|
3454
|
-
label: item.exit_code === 0 || item.status === 'completed' ? 'result' : 'exit ' + (item.exit_code ?? '?'),
|
|
3455
|
-
body: outShort,
|
|
3456
|
-
});
|
|
3457
|
-
}
|
|
3458
|
-
} else if (itemType === 'tool_call' || itemType === 'local_shell_call') {
|
|
3459
|
-
// Forward-compat: other codex tool types we haven't seen yet
|
|
3460
|
-
const cmd = String(item.command || item.tool_name || item.name || 'tool');
|
|
3461
|
-
const args = item.arguments ? JSON.stringify(item.arguments).slice(0, 200) : '';
|
|
3462
|
-
lines.push({ cls: 'tool', label: cmd, body: args });
|
|
3463
|
-
} else if (itemType === 'reasoning' || itemType === 'thinking') {
|
|
3464
|
-
// skip — too verbose
|
|
3465
|
-
} else {
|
|
3466
|
-
// Unknown item type — render minimally for diagnosis
|
|
3467
|
-
lines.push({ cls: 'result', label: itemType, body: JSON.stringify(item).slice(0, 200) });
|
|
3468
|
-
}
|
|
3469
|
-
} else if (type === 'turn.completed') {
|
|
3470
|
-
// Codex turn-completed event — light summary
|
|
3471
|
-
const u = (data.usage && typeof data.usage === 'object') ? data.usage : {};
|
|
3472
|
-
const input = Number(u.input_tokens || 0);
|
|
3473
|
-
const output = Number(u.output_tokens || 0);
|
|
3474
|
-
if (input || output) {
|
|
3475
|
-
lines.push({ cls: 'done', label: 'turn', body: input + ' in · ' + output + ' out tokens' });
|
|
3476
|
-
}
|
|
3477
|
-
} else if (type === 'exit') {
|
|
3478
|
-
const status = String(data.status || '?');
|
|
3479
|
-
const dur = Math.round(Number(data.durationMs || 0) / 1000);
|
|
3480
|
-
lines.push({ cls: 'exit', label: 'exit', body: 'status=' + status + ' duration=' + dur + 's' });
|
|
3481
|
-
} else if (type === 'codehere:error') {
|
|
3482
|
-
// F1b structured error with recovery hint
|
|
3483
|
-
const code = String(data.code || 'error');
|
|
3484
|
-
const msg = String(data.message || '');
|
|
3485
|
-
const recovery = String(data.recovery || '');
|
|
3486
|
-
const div = document.createElement('div');
|
|
3487
|
-
div.className = 'event codehere-error';
|
|
3488
|
-
div.innerHTML =
|
|
3489
|
-
'<span class="label">' + escape(code) + '</span>' +
|
|
3490
|
-
escape(msg) +
|
|
3491
|
-
(recovery ? '<span class="recovery">→ ' + escape(recovery) + '</span>' : '');
|
|
3492
|
-
logEl.appendChild(div);
|
|
3493
|
-
logEl.scrollTop = logEl.scrollHeight;
|
|
3494
|
-
return;
|
|
3495
|
-
} else if (type === 'error') {
|
|
3496
|
-
lines.push({ cls: 'error', label: 'error', body: String(data.message || JSON.stringify(data)) });
|
|
3497
|
-
} else if (type === 'stderr') {
|
|
3498
|
-
const t = String(data.line || '').trim();
|
|
3499
|
-
if (t) lines.push({ cls: 'stderr', label: 'stderr', body: t });
|
|
3500
|
-
} else if (type === 'stdout') {
|
|
3501
|
-
lines.push({ cls: 'stdout', label: 'out', body: String(data.line || '') });
|
|
3502
|
-
}
|
|
3503
|
-
// Any other event type → silent (we don't dump raw JSON anymore)
|
|
3504
|
-
|
|
3505
|
-
if (lines.length === 0) return;
|
|
3506
|
-
|
|
3507
|
-
const ts = now();
|
|
3508
|
-
for (const ln of lines) {
|
|
3509
|
-
const div = document.createElement('div');
|
|
3510
|
-
div.className = 'event ' + ln.cls;
|
|
3511
|
-
div.innerHTML =
|
|
3512
|
-
'<span class="t">' + escape(ts) + '</span>' +
|
|
3513
|
-
'<span class="label">' + escape(ln.label) + '</span>' +
|
|
3514
|
-
escape(ln.body);
|
|
3515
|
-
logEl.appendChild(div);
|
|
3516
|
-
}
|
|
3517
|
-
logEl.scrollTop = logEl.scrollHeight;
|
|
3518
|
-
}
|
|
3519
|
-
|
|
3520
|
-
// Status display labels: keep the underlying status string ('killed', 'error')
|
|
3521
|
-
// as the data-layer truth, but map to a calmer human label. May 6 2026 polish
|
|
3522
|
-
// per Open-Agents reference + claim_user_persona (non-technical readers
|
|
3523
|
-
// don't think of their work as "KILLED" — they think "I stopped it").
|
|
3524
|
-
// Pattern 6 · macro state → user-visible copy. Plain English; non-technical-
|
|
3525
|
-
// first per claim_user_persona HIGH. Each state's CSS class is the same
|
|
3526
|
-
// string with hyphens (e.g. status === 'rate_limit' → class="status-pill
|
|
3527
|
-
// rate-limit"); CSS classes can't contain underscores cleanly, so the
|
|
3528
|
-
// wire-format state has underscores and the class form has hyphens.
|
|
3529
|
-
// Per docs/design/AGENTIC_UX.md Pattern 6.
|
|
3530
|
-
var STATE_LABELS = {
|
|
3531
|
-
'thinking': 'Thinking',
|
|
3532
|
-
'running': 'Running',
|
|
3533
|
-
'running_buffered': 'Running (buffered)',
|
|
3534
|
-
'completed': 'Done',
|
|
3535
|
-
'awaiting': 'Waiting on you',
|
|
3536
|
-
'blocked': 'Blocked',
|
|
3537
|
-
'killed': 'Stopped',
|
|
3538
|
-
'crashed': 'Crashed',
|
|
3539
|
-
'error': 'Failed',
|
|
3540
|
-
'failed': 'Failed',
|
|
3541
|
-
'rate_limit': 'Rate-limited',
|
|
3542
|
-
'quota_exceeded': 'Quota exceeded',
|
|
3543
|
-
'credit_low': 'Credits low',
|
|
3544
|
-
'auth_invalid': 'Sign-in needed',
|
|
3545
|
-
'auth_missing': 'Sign-in needed',
|
|
3546
|
-
'model_unavailable':'Model unavailable'
|
|
3547
|
-
};
|
|
3548
|
-
function statusLabel(status) {
|
|
3549
|
-
if (!status) return 'Unknown';
|
|
3550
|
-
if (STATE_LABELS[status]) return STATE_LABELS[status];
|
|
3551
|
-
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
3552
|
-
}
|
|
3553
|
-
// Convert wire-format state ('rate_limit') to CSS class form ('rate-limit').
|
|
3554
|
-
function statusClass(status) {
|
|
3555
|
-
return String(status || '').replace(/_/g, '-');
|
|
3556
|
-
}
|
|
3557
|
-
function updateStatus(pillEl, status) {
|
|
3558
|
-
pillEl.className = 'status-pill ' + statusClass(status);
|
|
3559
|
-
pillEl.textContent = statusLabel(status);
|
|
3560
|
-
}
|
|
3561
|
-
|
|
3562
|
-
// Per-agent badge helpers (agentLabel, AGENT_SLOTS, AGENT_ICONS,
|
|
3563
|
-
// agentBadgeHTML, agentPresenceDotHTML) interpolated from AGENT_BADGE_JS
|
|
3564
|
-
// — single source of truth shared with THREAD_HTML and any other page
|
|
3565
|
-
// that needs vendor-attribution rendering. Per Pattern 10.
|
|
3566
|
-
${AGENT_BADGE_JS}
|
|
3567
|
-
|
|
3568
|
-
function attachSession(session) {
|
|
3569
|
-
const card = document.createElement('div');
|
|
3570
|
-
card.className = 'session';
|
|
3571
|
-
card.dataset.sessionId = session.sessionId;
|
|
3572
|
-
card.dataset.status = session.status;
|
|
3573
|
-
// C3 · Persisted sessions are from a prior daemon run — no live stream, no
|
|
3574
|
-
// kill. Still useful: user sees their previous Final reports across restarts.
|
|
3575
|
-
const isPersisted = session.provenance === 'persisted';
|
|
3576
|
-
const runningClass = session.status === 'running' ? ' badge-running-glow' : '';
|
|
3577
|
-
const liveDot = session.status === 'running' ? '<span class="live-dot"></span>' : '';
|
|
3578
|
-
card.innerHTML =
|
|
3579
|
-
'<div class="session-head">' +
|
|
3580
|
-
'<div class="meta">' +
|
|
3581
|
-
'<div class="prompt" title="' + escape(session.prompt) + '">' +
|
|
3582
|
-
escape(session.prompt) +
|
|
3583
|
-
'<span class="chevron">▸</span>' +
|
|
3584
|
-
'</div>' +
|
|
3585
|
-
'<div class="sub">' + liveDot + agentBadgeHTML(session.agent) + ' ' + escape(agentLabel(session.agent)) +
|
|
3586
|
-
(session.cwd ? ' · <span class="sub-cwd" title="' + escape(session.cwd) + '">' + escape(cwdTail(session.cwd)) + '</span>' : '') +
|
|
3587
|
-
' · ' + new Date(session.startedAt).toLocaleTimeString() +
|
|
3588
|
-
'<span class="safety-pill" style="display:none">' +
|
|
3589
|
-
'<span class="shield">🛡</span> ' +
|
|
3590
|
-
'<span class="safe">0</span> safe' +
|
|
3591
|
-
'<span class="blocked-portion" style="display:none"> · <span class="blocked">0</span> blocked</span>' +
|
|
3592
|
-
'</span>' +
|
|
3593
|
-
'</div>' +
|
|
3594
|
-
'<div class="tool-progress-chip" data-state="hidden"></div>' +
|
|
3595
|
-
'<div class="micro-activity"></div>' +
|
|
3596
|
-
'<div class="steps-mini"></div>' +
|
|
3597
|
-
'</div>' +
|
|
3598
|
-
'<div class="session-controls">' +
|
|
3599
|
-
'<div class="view-toggle" title="Switch between thread view and raw event log">' +
|
|
3600
|
-
'<button class="view-thread active" data-view="thread">Thread</button>' +
|
|
3601
|
-
'<button class="view-raw" data-view="raw">Log</button>' +
|
|
3602
|
-
'</div>' +
|
|
3603
|
-
'<span class="status-pill ' + session.status + runningClass + '">' + statusLabel(session.status) + '</span>' +
|
|
3604
|
-
'<button class="kill">Kill</button>' +
|
|
3605
|
-
'</div>' +
|
|
3606
|
-
'</div>' +
|
|
3607
|
-
'<div class="session-body" data-view="thread">' +
|
|
3608
|
-
// Session threading — "Continued from" indicator for failover sessions
|
|
3609
|
-
(session.continuedFrom ? '<div class="session-continued-from">Continued from <a href="#' + escape(session.continuedFrom) + '">' + escape(agentLabel(session.agent)) + '</a> session</div>' : '') +
|
|
3610
|
-
// Order: thread messages (chronological) → final report (closing
|
|
3611
|
-
// summary, rendered as a block when artifacts arrive) → reply input.
|
|
3612
|
-
// Apr 29 bug fix: previously final-report was BEFORE thread-messages
|
|
3613
|
-
// so on completed sessions the Final Report appeared above the user's
|
|
3614
|
-
// seed prompt, breaking chronological order in thread view.
|
|
3615
|
-
'<div class="thread-messages"></div>' +
|
|
3616
|
-
// P18 slice 4 — permission-gate stack. Empty container; panels
|
|
3617
|
-
// get inserted by the per-session blocks poll when the per-tool
|
|
3618
|
-
// hook fires for this session. Each block becomes one panel with
|
|
3619
|
-
// an "Approve in this folder" button.
|
|
3620
|
-
'<div class="permission-gates"></div>' +
|
|
3621
|
-
// v0.6 M3 — PM narrator block. Hidden until first narration
|
|
3622
|
-
// arrives; renders one paragraph plain-English of what the
|
|
3623
|
-
// agent is doing right now. Per claim_user_persona HIGH:
|
|
3624
|
-
// non-technical users read this, not the tool calls.
|
|
3625
|
-
// Per feedback_overconfidence_guard.md: framed as "narrator"
|
|
3626
|
-
// not authoritative truth — narration is an LLM call, may
|
|
3627
|
-
// hallucinate.
|
|
3628
|
-
'<div class="narrator-block" style="display:none">' +
|
|
3629
|
-
'<div class="narrator-label">narrator</div>' +
|
|
3630
|
-
'<div class="narrator-text"></div>' +
|
|
3631
|
-
'<div class="narrator-meta"></div>' +
|
|
3632
|
-
'</div>' +
|
|
3633
|
-
'<div class="final-report" style="display:none"></div>' +
|
|
3634
|
-
// Iter 12 — reply surface now functional; replaced the prior dead
|
|
3635
|
-
// placeholder. Active for adapters with canAppendMessage=true
|
|
3636
|
-
// (claude-code today); disabled state for adapters that don't yet
|
|
3637
|
-
// implement appendMessage.
|
|
3638
|
-
'<div class="thread-input">' +
|
|
3639
|
-
'<textarea class="reply-text" placeholder="Reply to thread… (Cmd+Enter to send)" rows="2"></textarea>' +
|
|
3640
|
-
'<button class="reply-send" type="button">Send</button>' +
|
|
3641
|
-
'<div class="reply-status" style="display:none"></div>' +
|
|
3642
|
-
'</div>' +
|
|
3643
|
-
'<div class="event-log"></div>' +
|
|
3644
|
-
'</div>' +
|
|
3645
|
-
// Pattern 3 — session footer. Hidden until populated (data-empty=true);
|
|
3646
|
-
// renderToolProgress flips it to data-empty=false once steps appear.
|
|
3647
|
-
'<div class="session-foot" data-empty="true">' +
|
|
3648
|
-
'<span class="sf-item sf-elapsed"><b>0s</b><span>elapsed</span></span>' +
|
|
3649
|
-
'<span class="sf-sep">·</span>' +
|
|
3650
|
-
'<span class="sf-item sf-tools"><b>0</b><span>tools</span></span>' +
|
|
3651
|
-
'<span class="sf-sep">·</span>' +
|
|
3652
|
-
'<span class="sf-item sf-files"><b>0</b><span>files</span></span>' +
|
|
3653
|
-
'</div>';
|
|
3654
|
-
|
|
3655
|
-
sessionsEl.prepend(card);
|
|
3656
|
-
emptyEl.style.display = 'none';
|
|
3657
|
-
// v0.6 S2 — first session attached means user has done step 1+2;
|
|
3658
|
-
// dismiss the onboarding card. They can re-open by clearing local
|
|
3659
|
-
// storage, but for the typical flow this is "I'm using it now".
|
|
3660
|
-
dismissOnboarding();
|
|
3661
|
-
|
|
3662
|
-
// Session threading — "continued as" indicator for sessions that already
|
|
3663
|
-
// have a failover successor (e.g., after page refresh).
|
|
3664
|
-
if (session.continuedAs) {
|
|
3665
|
-
var continuedAsEl = document.createElement('div');
|
|
3666
|
-
continuedAsEl.className = 'session-continued-as';
|
|
3667
|
-
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(session.continuedAs) + '">new session</a> · session ' + escape(session.continuedAs.slice(0, 8));
|
|
3668
|
-
var headEl2 = card.querySelector('.session-head');
|
|
3669
|
-
if (headEl2 && headEl2.parentNode) headEl2.parentNode.insertBefore(continuedAsEl, headEl2.nextSibling);
|
|
3670
|
-
}
|
|
3671
|
-
|
|
3672
|
-
const logEl = card.querySelector('.event-log');
|
|
3673
|
-
const pillEl = card.querySelector('.status-pill');
|
|
3674
|
-
const killBtn = card.querySelector('.kill');
|
|
3675
|
-
const headEl = card.querySelector('.session-head');
|
|
3676
|
-
const stepsEl = card.querySelector('.steps-mini');
|
|
3677
|
-
const microActivityEl = card.querySelector('.micro-activity');
|
|
3678
|
-
const toolProgressEl = card.querySelector('.tool-progress-chip');
|
|
3679
|
-
// Pattern 1 — tool-progress live chip. Renders "12 tools | 3/4 ●●●○ | Ns".
|
|
3680
|
-
// Sources from the same codehere:steps synthetic event renderSteps reads.
|
|
3681
|
-
function fmtElapsed(ms) {
|
|
3682
|
-
var s = Math.max(0, Math.floor(ms / 1000));
|
|
3683
|
-
if (s < 60) return s + 's';
|
|
3684
|
-
var m = Math.floor(s / 60);
|
|
3685
|
-
var rem = s % 60;
|
|
3686
|
-
if (m < 60) return rem ? m + 'm ' + rem + 's' : m + 'm';
|
|
3687
|
-
var h = Math.floor(m / 60);
|
|
3688
|
-
return h + 'h ' + (m % 60) + 'm';
|
|
3689
|
-
}
|
|
3690
|
-
function renderToolProgress(steps) {
|
|
3691
|
-
if (!toolProgressEl) return;
|
|
3692
|
-
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3693
|
-
toolProgressEl.dataset.state = 'hidden';
|
|
3694
|
-
toolProgressEl.innerHTML = '';
|
|
3695
|
-
return;
|
|
3696
|
-
}
|
|
3697
|
-
// Compute counts + state.
|
|
3698
|
-
var done = 0, active = 0, failed = 0, pending = 0;
|
|
3699
|
-
for (var i = 0; i < steps.length; i++) {
|
|
3700
|
-
var st = String(steps[i].state || '');
|
|
3701
|
-
if (st === 'done') done++;
|
|
3702
|
-
else if (st === 'active') active++;
|
|
3703
|
-
else if (st === 'failed') failed++;
|
|
3704
|
-
else pending++;
|
|
3705
|
-
}
|
|
3706
|
-
var total = steps.length;
|
|
3707
|
-
var chipState;
|
|
3708
|
-
if (failed > 0) chipState = 'failed';
|
|
3709
|
-
else if (session.status !== 'running') chipState = (done === total ? 'complete' : 'paused');
|
|
3710
|
-
else if (active > 0) chipState = 'running';
|
|
3711
|
-
else chipState = 'paused';
|
|
3712
|
-
|
|
3713
|
-
// Dot pattern — last 6 steps, oldest first. ● = done, ◐ = active,
|
|
3714
|
-
// ○ = pending, ✗ = failed. Cap at 6 so the chip doesn't grow.
|
|
3715
|
-
var tail = steps.slice(-6);
|
|
3716
|
-
var dots = tail.map(function(s) {
|
|
3717
|
-
var st = String(s.state || '');
|
|
3718
|
-
if (st === 'done') return '●';
|
|
3719
|
-
if (st === 'active') return '◐';
|
|
3720
|
-
if (st === 'failed') return '✗';
|
|
3721
|
-
return '○';
|
|
3722
|
-
}).join('');
|
|
3723
|
-
|
|
3724
|
-
var elapsed = fmtElapsed(Date.now() - session.startedAt);
|
|
3725
|
-
toolProgressEl.dataset.state = chipState;
|
|
3726
|
-
toolProgressEl.innerHTML =
|
|
3727
|
-
'<span>' + total + ' tool' + (total === 1 ? '' : 's') + '</span>' +
|
|
3728
|
-
'<span class="tp-sep">|</span>' +
|
|
3729
|
-
'<span>' + done + '/' + total + ' <span class="tp-dots">' + dots + '</span></span>' +
|
|
3730
|
-
'<span class="tp-sep">|</span>' +
|
|
3731
|
-
'<span class="tp-elapsed">' + elapsed + '</span>';
|
|
3732
|
-
|
|
3733
|
-
// Pattern 3 — keep the footer in sync with the same step data the
|
|
3734
|
-
// chip reads. Cheaper than a separate event subscription and stays
|
|
3735
|
-
// structurally consistent with the live counts above.
|
|
3736
|
-
renderSessionFoot(steps);
|
|
3737
|
-
}
|
|
3738
|
-
// Pattern 3 — session-foot: elapsed · tools · files. Reveals on first
|
|
3739
|
-
// step; updates counts on every renderSteps call. Read-only — no
|
|
3740
|
-
// click affordance (rec'y / file-list belong elsewhere).
|
|
3741
|
-
var sessionFootEl = card.querySelector('.session-foot');
|
|
3742
|
-
function renderSessionFoot(steps) {
|
|
3743
|
-
if (!sessionFootEl) return;
|
|
3744
|
-
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3745
|
-
sessionFootEl.dataset.empty = 'true';
|
|
3746
|
-
return;
|
|
3747
|
-
}
|
|
3748
|
-
sessionFootEl.dataset.empty = 'false';
|
|
3749
|
-
var elapsed = session.completedAt
|
|
3750
|
-
? fmtElapsed(session.completedAt - session.startedAt)
|
|
3751
|
-
: fmtElapsed(Date.now() - session.startedAt);
|
|
3752
|
-
var elapsedB = sessionFootEl.querySelector('.sf-elapsed b');
|
|
3753
|
-
var toolsB = sessionFootEl.querySelector('.sf-tools b');
|
|
3754
|
-
var filesB = sessionFootEl.querySelector('.sf-files b');
|
|
3755
|
-
if (elapsedB) elapsedB.textContent = elapsed;
|
|
3756
|
-
if (toolsB) toolsB.textContent = String(steps.length);
|
|
3757
|
-
if (filesB) filesB.textContent = String(countTouchedFiles(steps));
|
|
3758
|
-
}
|
|
3759
|
-
// Tick elapsed display every second while running so the chip feels live.
|
|
3760
|
-
var elapsedTicker = null;
|
|
3761
|
-
function startElapsedTick() {
|
|
3762
|
-
if (elapsedTicker) return;
|
|
3763
|
-
elapsedTicker = setInterval(function() {
|
|
3764
|
-
if (!toolProgressEl || toolProgressEl.dataset.state === 'hidden') return;
|
|
3765
|
-
var elEl = toolProgressEl.querySelector('.tp-elapsed');
|
|
3766
|
-
if (elEl) elEl.textContent = fmtElapsed(Date.now() - session.startedAt);
|
|
3767
|
-
}, 1000);
|
|
3768
|
-
}
|
|
3769
|
-
function stopElapsedTick() {
|
|
3770
|
-
if (elapsedTicker) { clearInterval(elapsedTicker); elapsedTicker = null; }
|
|
3771
|
-
}
|
|
3772
|
-
if (session.status === 'running') startElapsedTick();
|
|
3773
|
-
|
|
3774
|
-
// v0.6 M3 — PM narrator polling. Backend generates a one-paragraph
|
|
3775
|
-
// mid-task summary every 30s while the session runs; frontend polls
|
|
3776
|
-
// /api/task/:id/narration and renders it in the narrator-block.
|
|
3777
|
-
// Per claim_user_persona: non-technical users read narration, not
|
|
3778
|
-
// tool calls. Per overconfidence-guard: framed as "narrator", not
|
|
3779
|
-
// authoritative truth.
|
|
3780
|
-
var narrTicker = null;
|
|
3781
|
-
var lastNarrAt = 0;
|
|
3782
|
-
function pollNarration() {
|
|
3783
|
-
fetch('/api/task/' + session.sessionId + '/narration')
|
|
3784
|
-
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3785
|
-
.then(function(d) {
|
|
3786
|
-
if (!d || !d.narration) return;
|
|
3787
|
-
var n = d.narration;
|
|
3788
|
-
if (n.generatedAt <= lastNarrAt) return; // unchanged
|
|
3789
|
-
lastNarrAt = n.generatedAt;
|
|
3790
|
-
var block = card.querySelector('.narrator-block');
|
|
3791
|
-
if (!block) return;
|
|
3792
|
-
var textEl = block.querySelector('.narrator-text');
|
|
3793
|
-
var metaEl = block.querySelector('.narrator-meta');
|
|
3794
|
-
if (textEl) textEl.textContent = n.summary;
|
|
3795
|
-
if (metaEl) {
|
|
3796
|
-
var ago = Math.max(0, Math.floor((Date.now() - n.generatedAt) / 1000));
|
|
3797
|
-
metaEl.textContent = 'after ' + n.stepCountAtSnapshot + ' steps · ' + ago + 's ago · may be inaccurate';
|
|
3798
|
-
}
|
|
3799
|
-
block.style.display = 'block';
|
|
3800
|
-
})
|
|
3801
|
-
.catch(function() { /* skip */ });
|
|
3802
|
-
}
|
|
3803
|
-
function startNarrationTick() {
|
|
3804
|
-
if (narrTicker) return;
|
|
3805
|
-
pollNarration(); // immediate
|
|
3806
|
-
narrTicker = setInterval(pollNarration, 30000);
|
|
3807
|
-
}
|
|
3808
|
-
function stopNarrationTick() {
|
|
3809
|
-
if (narrTicker) { clearInterval(narrTicker); narrTicker = null; }
|
|
3810
|
-
}
|
|
3811
|
-
if (session.status === 'running') startNarrationTick();
|
|
3812
|
-
|
|
3813
|
-
// P18 slice 4 — poll /api/task/:id/blocks for hook-blocks against
|
|
3814
|
-
// this session. Render one .permission-gate panel per unique block.
|
|
3815
|
-
// De-dup by hash of category + matched + timestamp.
|
|
3816
|
-
var blockTicker = null;
|
|
3817
|
-
var seenBlockKeys = Object.create(null);
|
|
3818
|
-
function escapeHtmlInline(s) {
|
|
3819
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
|
3820
|
-
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
3821
|
-
});
|
|
3822
|
-
}
|
|
3823
|
-
function renderBlockGate(block) {
|
|
3824
|
-
// The block entry shape comes from the audit log; toolInput may be
|
|
3825
|
-
// a JSON string with {agent, command, allowedByPermissions, ...}
|
|
3826
|
-
var key = (block.timestamp || '') + '|' + (block.toolName || '') + '|' + (block.findings || 0);
|
|
3827
|
-
if (seenBlockKeys[key]) return;
|
|
3828
|
-
seenBlockKeys[key] = true;
|
|
3829
|
-
var gateContainer = card.querySelector('.permission-gates');
|
|
3830
|
-
if (!gateContainer) return;
|
|
3831
|
-
// Pull a useful matched-text + cwd out of the block. The audit
|
|
3832
|
-
// entry surfaces them in toolInput JSON shape.
|
|
3833
|
-
var pattern = '';
|
|
3834
|
-
var cwd = '';
|
|
3835
|
-
try {
|
|
3836
|
-
var parsedInput = typeof block.toolInput === 'string' ? JSON.parse(block.toolInput) : block.toolInput;
|
|
3837
|
-
if (parsedInput && typeof parsedInput === 'object') {
|
|
3838
|
-
// command (Bash) or file_path (Edit/Write) — best-effort
|
|
3839
|
-
pattern = String(parsedInput.command || parsedInput.file_path || '');
|
|
3840
|
-
cwd = String(parsedInput.cwd || '');
|
|
3841
|
-
}
|
|
3842
|
-
} catch (_) { /* not JSON */ }
|
|
3843
|
-
var truncatedPattern = pattern.length > 200 ? pattern.slice(0, 200) + '…' : pattern;
|
|
3844
|
-
var gate = document.createElement('div');
|
|
3845
|
-
gate.className = 'permission-gate';
|
|
3846
|
-
gate.innerHTML =
|
|
3847
|
-
'<div class="pg-label">codehere noticed a pattern</div>' +
|
|
3848
|
-
'<div class="pg-title">Is this expected here? You can approve it for this folder so future runs do not stop on the same pattern.</div>' +
|
|
3849
|
-
'<div class="pg-detail">' + escapeHtmlInline(truncatedPattern || '(no command captured)') + '</div>' +
|
|
3850
|
-
'<div class="pg-actions">' +
|
|
3851
|
-
'<button type="button" class="pg-approve">Approve in this folder</button>' +
|
|
3852
|
-
'<button type="button" class="pg-keep">Keep blocking</button>' +
|
|
3853
|
-
'</div>' +
|
|
3854
|
-
'<div class="pg-status">Approved · added to .codehere/permissions.yaml</div>';
|
|
3855
|
-
gateContainer.appendChild(gate);
|
|
3856
|
-
gate.querySelector('.pg-keep').addEventListener('click', function() {
|
|
3857
|
-
gate.remove();
|
|
3858
|
-
});
|
|
3859
|
-
gate.querySelector('.pg-approve').addEventListener('click', function() {
|
|
3860
|
-
var btn = gate.querySelector('.pg-approve');
|
|
3861
|
-
btn.disabled = true;
|
|
3862
|
-
btn.textContent = '…';
|
|
3863
|
-
// Approve the EXACT pattern that triggered the block; reason
|
|
3864
|
-
// captures the block's matched-finding context for the audit.
|
|
3865
|
-
// Fall back to a substring of the command if no finding text.
|
|
3866
|
-
var approvePattern = pattern;
|
|
3867
|
-
var reasonSuffix = block.findings ? (block.findings + ' finding' + (block.findings === 1 ? '' : 's') + ' caught at this site') : 'caught by hook';
|
|
3868
|
-
var reason = 'Approved via dashboard panel · ' + reasonSuffix;
|
|
3869
|
-
if (!cwd) {
|
|
3870
|
-
alert('Cannot approve: no cwd captured in the audit entry. Use codehere permission allow on the CLI.');
|
|
3871
|
-
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3872
|
-
return;
|
|
3873
|
-
}
|
|
3874
|
-
fetch('/api/permissions', {
|
|
3875
|
-
method: 'POST',
|
|
3876
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3877
|
-
body: JSON.stringify({ cwd: cwd, pattern: approvePattern, reason: reason }),
|
|
3878
|
-
})
|
|
3879
|
-
.then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
|
|
3880
|
-
.then(function() {
|
|
3881
|
-
gate.classList.add('approved');
|
|
3882
|
-
})
|
|
3883
|
-
.catch(function() {
|
|
3884
|
-
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3885
|
-
alert('Approve failed — try the CLI: codehere permission allow ' + JSON.stringify(approvePattern));
|
|
3886
|
-
});
|
|
3887
|
-
});
|
|
3888
|
-
}
|
|
3889
|
-
function pollBlocks() {
|
|
3890
|
-
fetch('/api/task/' + session.sessionId + '/blocks')
|
|
3891
|
-
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3892
|
-
.then(function(d) {
|
|
3893
|
-
if (!d || !Array.isArray(d.blocks)) return;
|
|
3894
|
-
for (var i = 0; i < d.blocks.length; i++) renderBlockGate(d.blocks[i]);
|
|
3895
|
-
})
|
|
3896
|
-
.catch(function() { /* skip */ });
|
|
3897
|
-
}
|
|
3898
|
-
function startBlockTick() {
|
|
3899
|
-
if (blockTicker) return;
|
|
3900
|
-
pollBlocks();
|
|
3901
|
-
blockTicker = setInterval(pollBlocks, 30000);
|
|
3902
|
-
}
|
|
3903
|
-
function stopBlockTick() {
|
|
3904
|
-
if (blockTicker) { clearInterval(blockTicker); blockTicker = null; }
|
|
3905
|
-
}
|
|
3906
|
-
if (session.status === 'running') startBlockTick();
|
|
3907
|
-
|
|
3908
|
-
// Pattern 13 — start whimsy rotator only while status === 'running' and
|
|
3909
|
-
// there's no active tool step. stopWhimsy() runs on exit (or on every
|
|
3910
|
-
// active-step swap; we restart it when the active step ends).
|
|
3911
|
-
var stopWhimsy = null;
|
|
3912
|
-
var lastMode = ''; // 'whimsy' | 'tool' | ''
|
|
3913
|
-
function setMicroState(mode, label) {
|
|
3914
|
-
if (!microActivityEl) return;
|
|
3915
|
-
if (mode === '') {
|
|
3916
|
-
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3917
|
-
microActivityEl.classList.remove('live', 'tool');
|
|
3918
|
-
microActivityEl.textContent = '';
|
|
3919
|
-
lastMode = '';
|
|
3920
|
-
return;
|
|
3921
|
-
}
|
|
3922
|
-
if (mode === 'tool') {
|
|
3923
|
-
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3924
|
-
microActivityEl.classList.add('live', 'tool');
|
|
3925
|
-
microActivityEl.textContent = label || 'Working…';
|
|
3926
|
-
lastMode = 'tool';
|
|
3927
|
-
return;
|
|
3928
|
-
}
|
|
3929
|
-
// mode === 'whimsy'
|
|
3930
|
-
microActivityEl.classList.add('live');
|
|
3931
|
-
microActivityEl.classList.remove('tool');
|
|
3932
|
-
if (lastMode !== 'whimsy') {
|
|
3933
|
-
if (stopWhimsy) stopWhimsy();
|
|
3934
|
-
stopWhimsy = startWhimsyRotator(microActivityEl);
|
|
3935
|
-
lastMode = 'whimsy';
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
|
-
if (session.status === 'running') setMicroState('whimsy');
|
|
3939
|
-
const finalReportEl = card.querySelector('.final-report');
|
|
3940
|
-
const threadEl = card.querySelector('.thread-messages');
|
|
3941
|
-
const bodyEl = card.querySelector('.session-body');
|
|
3942
|
-
// Iter 12 — reply-to-thread refs + handler.
|
|
3943
|
-
const threadInputEl = card.querySelector('.thread-input');
|
|
3944
|
-
const replyTextEl = card.querySelector('.reply-text');
|
|
3945
|
-
const replySendBtn = card.querySelector('.reply-send');
|
|
3946
|
-
const replyStatusEl = card.querySelector('.reply-status');
|
|
3947
|
-
if (!session.canAppendMessage) {
|
|
3948
|
-
threadInputEl.classList.add('disabled');
|
|
3949
|
-
threadInputEl.title = 'Replies require a live session — re-spawn to continue this thread';
|
|
3950
|
-
// All adapters wire appendMessage as of v0.6.0 Slices 1-3. This branch
|
|
3951
|
-
// now only fires for sessions evicted from in-memory tracking (the
|
|
3952
|
-
// canAppendMessage flag persists from spawn but the API endpoint
|
|
3953
|
-
// requires the session to still be live in SessionManager).
|
|
3954
|
-
replyTextEl.placeholder = 'Replies require a live session — re-spawn to continue this thread';
|
|
3955
|
-
replyTextEl.disabled = true;
|
|
3956
|
-
replySendBtn.disabled = true;
|
|
3957
|
-
} else {
|
|
3958
|
-
const sendReply = async () => {
|
|
3959
|
-
const text = (replyTextEl.value || '').trim();
|
|
3960
|
-
if (!text) return;
|
|
3961
|
-
replySendBtn.disabled = true;
|
|
3962
|
-
replySendBtn.textContent = '...';
|
|
3963
|
-
replyStatusEl.style.display = 'none';
|
|
3964
|
-
replyStatusEl.classList.remove('error');
|
|
3965
|
-
// Optimistic: render the user's message immediately in the thread.
|
|
3966
|
-
appendThreadMessage(threadMessage({
|
|
3967
|
-
cls: 'user', author: 'You', time: new Date(), content: text,
|
|
3968
|
-
}));
|
|
3969
|
-
replyTextEl.value = '';
|
|
3970
|
-
try {
|
|
3971
|
-
const r = await fetch('/api/task/' + session.sessionId + '/message', {
|
|
3972
|
-
method: 'POST',
|
|
3973
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3974
|
-
body: JSON.stringify({ text }),
|
|
3975
|
-
});
|
|
3976
|
-
const data = await r.json();
|
|
3977
|
-
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
3978
|
-
// Render assistant response in the thread.
|
|
3979
|
-
appendThreadMessage(threadMessage({
|
|
3980
|
-
cls: 'agent', author: agentLabel(session.agent), time: new Date(),
|
|
3981
|
-
content: String(data.result || '(empty response)'),
|
|
3982
|
-
}));
|
|
3983
|
-
} catch (err) {
|
|
3984
|
-
replyStatusEl.textContent = 'Reply failed: ' + (err && err.message ? err.message : String(err));
|
|
3985
|
-
replyStatusEl.classList.add('error');
|
|
3986
|
-
replyStatusEl.style.display = 'block';
|
|
3987
|
-
} finally {
|
|
3988
|
-
replySendBtn.disabled = false;
|
|
3989
|
-
replySendBtn.textContent = 'Send';
|
|
3990
|
-
replyTextEl.focus();
|
|
3991
|
-
}
|
|
3992
|
-
};
|
|
3993
|
-
replySendBtn.addEventListener('click', sendReply);
|
|
3994
|
-
replyTextEl.addEventListener('keydown', (ev) => {
|
|
3995
|
-
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') { ev.preventDefault(); sendReply(); }
|
|
3996
|
-
});
|
|
3997
|
-
}
|
|
3998
|
-
const safetyPillEl = card.querySelector('.safety-pill');
|
|
3999
|
-
const safetySafeEl = safetyPillEl.querySelector('.safe');
|
|
4000
|
-
const safetyBlockedEl = safetyPillEl.querySelector('.blocked');
|
|
4001
|
-
const safetyBlockedWrap = safetyPillEl.querySelector('.blocked-portion');
|
|
4002
|
-
let safeCount = 0, blockedCount = 0;
|
|
4003
|
-
|
|
4004
|
-
// Gate B1 · increment safety counters on every Claude hook_response.
|
|
4005
|
-
// Principle #7 (agent-provenance): shows the hook firing continuously,
|
|
4006
|
-
// so the safety moat is visible mid-task, not only when something blocks.
|
|
4007
|
-
function bumpSafety(event) {
|
|
4008
|
-
if (event.type !== 'system') return;
|
|
4009
|
-
const d = event.data || {};
|
|
4010
|
-
if (d.subtype !== 'hook_response') return;
|
|
4011
|
-
if (Number(d.exit_code ?? 0) === 0) safeCount += 1; else blockedCount += 1;
|
|
4012
|
-
safetyPillEl.style.display = 'inline-flex';
|
|
4013
|
-
safetySafeEl.textContent = String(safeCount);
|
|
4014
|
-
if (blockedCount > 0) {
|
|
4015
|
-
safetyBlockedWrap.style.display = '';
|
|
4016
|
-
safetyBlockedEl.textContent = String(blockedCount);
|
|
4017
|
-
safetyPillEl.classList.add('has-blocked');
|
|
4018
|
-
}
|
|
4019
|
-
}
|
|
4020
|
-
|
|
4021
|
-
// Collapse/expand on header click (but not when clicking Kill)
|
|
4022
|
-
headEl.addEventListener('click', (ev) => {
|
|
4023
|
-
if (ev.target && ev.target.closest('.session-controls')) return;
|
|
4024
|
-
card.classList.toggle('expanded');
|
|
4025
|
-
});
|
|
4026
|
-
|
|
4027
|
-
// Apr 29 — Slack-shape thread view.
|
|
4028
|
-
// Thread/Raw toggle in the card head. Default = thread; Raw stays available
|
|
4029
|
-
// for power users who want the unfiltered event stream.
|
|
4030
|
-
const viewBtns = card.querySelectorAll('.view-toggle button');
|
|
4031
|
-
viewBtns.forEach((btn) => {
|
|
4032
|
-
btn.addEventListener('click', (ev) => {
|
|
4033
|
-
ev.stopPropagation(); // don't collapse the card
|
|
4034
|
-
const view = btn.dataset.view;
|
|
4035
|
-
bodyEl.dataset.view = view;
|
|
4036
|
-
viewBtns.forEach((b) => b.classList.toggle('active', b === btn));
|
|
4037
|
-
});
|
|
4038
|
-
});
|
|
4039
|
-
|
|
4040
|
-
// Build a single thread message DOM string.
|
|
4041
|
-
function threadMessage(opts) {
|
|
4042
|
-
const cls = opts.cls || 'agent';
|
|
4043
|
-
const av = opts.avatar || (cls === 'user' ? '👤' : (cls === 'tool' ? '⚙' : (cls === 'hook' ? '🛡' : (cls === 'error' ? '✗' : '🤖'))));
|
|
4044
|
-
const time = opts.time || new Date();
|
|
4045
|
-
return '<div class="thread-message ' + escape(cls) + (opts.modifier ? ' ' + escape(opts.modifier) : '') + '">' +
|
|
4046
|
-
'<div class="avatar">' + escape(av) + '</div>' +
|
|
4047
|
-
'<div class="body">' +
|
|
4048
|
-
'<div class="head">' +
|
|
4049
|
-
'<span class="author">' + escape(opts.author || 'agent') + '</span>' +
|
|
4050
|
-
'<span class="time">' + time.toLocaleTimeString() + '</span>' +
|
|
4051
|
-
'</div>' +
|
|
4052
|
-
'<div class="content">' + escape(String(opts.content || '')) + '</div>' +
|
|
4053
|
-
'</div>' +
|
|
4054
|
-
'</div>';
|
|
4055
|
-
}
|
|
4056
|
-
|
|
4057
|
-
function appendThreadMessage(html) {
|
|
4058
|
-
if (!html) return;
|
|
4059
|
-
threadEl.insertAdjacentHTML('beforeend', html);
|
|
4060
|
-
// Auto-scroll to bottom of thread (within the card)
|
|
4061
|
-
threadEl.scrollTop = threadEl.scrollHeight;
|
|
4062
|
-
}
|
|
4063
|
-
|
|
4064
|
-
// Seed: user prompt as the first thread message
|
|
4065
|
-
threadEl.innerHTML = threadMessage({
|
|
4066
|
-
cls: 'user',
|
|
4067
|
-
author: 'You',
|
|
4068
|
-
time: new Date(session.startedAt),
|
|
4069
|
-
content: session.prompt,
|
|
4070
|
-
});
|
|
4071
|
-
|
|
4072
|
-
// Parallel event → thread renderer. Initial pass duplicates parts of
|
|
4073
|
-
// renderEvent's branching; a follow-up refactor extracts a shared
|
|
4074
|
-
// eventToLines() primitive feeding both renderers.
|
|
4075
|
-
function renderEventToThread(event) {
|
|
4076
|
-
const type = String(event.type || '');
|
|
4077
|
-
const data = event.data ?? {};
|
|
4078
|
-
const t = new Date(event.timestamp || Date.now());
|
|
4079
|
-
const agent = agentLabel(session.agent);
|
|
4080
|
-
|
|
4081
|
-
if (type === 'stream_event') return;
|
|
4082
|
-
if (type === 'system' && (data.subtype === 'init' || data.subtype === 'status' || data.subtype === 'hook_started')) return;
|
|
4083
|
-
if (type === 'stdout' && !String(data.line || '').trim()) return;
|
|
4084
|
-
if (type === 'thread.started' || type === 'turn.started') return;
|
|
4085
|
-
if (type === 'item.started') return;
|
|
4086
|
-
|
|
4087
|
-
if (type === 'assistant' && data.message && Array.isArray(data.message.content)) {
|
|
4088
|
-
for (const block of data.message.content) {
|
|
4089
|
-
if (block.type === 'text') {
|
|
4090
|
-
const txt = String(block.text || '').trim();
|
|
4091
|
-
if (txt) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: txt }));
|
|
4092
|
-
} else if (block.type === 'tool_use') {
|
|
4093
|
-
const name = String(block.name || 'tool');
|
|
4094
|
-
const input = block.input && typeof block.input === 'object' ? JSON.stringify(block.input) : '';
|
|
4095
|
-
const preview = input.length > 200 ? input.slice(0, 200) + '…' : input;
|
|
4096
|
-
appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · ' + name, time: t, content: preview, avatar: '⚙' }));
|
|
4097
|
-
}
|
|
4098
|
-
}
|
|
4099
|
-
} else if (type === 'user' && data.message && Array.isArray(data.message.content)) {
|
|
4100
|
-
for (const block of data.message.content) {
|
|
4101
|
-
if (block.type === 'tool_result') {
|
|
4102
|
-
const raw = Array.isArray(block.content) ? block.content.map((c) => c.text || '').join('') : String(block.content || '');
|
|
4103
|
-
const txt = raw.replace(/\\s+/g, ' ').trim();
|
|
4104
|
-
if (txt) {
|
|
4105
|
-
const preview = txt.length > 240 ? txt.slice(0, 240) + '…' : txt;
|
|
4106
|
-
const cls = block.is_error ? 'error' : 'tool';
|
|
4107
|
-
const author = block.is_error ? agent + ' · error' : agent + ' · result';
|
|
4108
|
-
appendThreadMessage(threadMessage({ cls, author, time: t, content: preview }));
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
}
|
|
4112
|
-
} else if (type === 'system' && data.subtype === 'hook_response') {
|
|
4113
|
-
const name = String(data.hook_name || 'hook');
|
|
4114
|
-
const blocked = Number(data.exit_code ?? 0) !== 0;
|
|
4115
|
-
const stderr = String(data.stderr || '').trim();
|
|
4116
|
-
const content = blocked ? (stderr || 'blocked') : 'allowed';
|
|
4117
|
-
appendThreadMessage(threadMessage({ cls: 'hook', modifier: blocked ? 'blocked' : '', author: 'codehere · ' + name, time: t, content }));
|
|
4118
|
-
} else if (type === 'item.completed') {
|
|
4119
|
-
const item = data.item && typeof data.item === 'object' ? data.item : {};
|
|
4120
|
-
const itemType = String(item.type || 'item');
|
|
4121
|
-
if (itemType === 'agent_message' && item.text) {
|
|
4122
|
-
const txt = String(item.text).trim();
|
|
4123
|
-
if (txt) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: txt.slice(0, 320) }));
|
|
4124
|
-
} else if (itemType === 'command_execution') {
|
|
4125
|
-
const cmd = String(item.command || '').replace(/\\n/g, ' ').trim();
|
|
4126
|
-
const cmdShort = cmd.length > 160 ? cmd.slice(0, 160) + '…' : cmd;
|
|
4127
|
-
appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · shell', time: t, content: cmdShort, avatar: '⚙' }));
|
|
4128
|
-
const out = String(item.aggregated_output || '').trim();
|
|
4129
|
-
if (out) {
|
|
4130
|
-
const outShort = out.length > 200 ? out.slice(0, 200) + '…' : out;
|
|
4131
|
-
const ok = item.exit_code === 0 || item.status === 'completed';
|
|
4132
|
-
appendThreadMessage(threadMessage({
|
|
4133
|
-
cls: ok ? 'tool' : 'error',
|
|
4134
|
-
author: agent + ' · ' + (ok ? 'result' : 'exit ' + (item.exit_code ?? '?')),
|
|
4135
|
-
time: t,
|
|
4136
|
-
content: outShort,
|
|
4137
|
-
}));
|
|
4138
|
-
}
|
|
4139
|
-
}
|
|
4140
|
-
} else if (type === 'stdout') {
|
|
4141
|
-
// Aider and generic CLI adapters stream via plain stdout. Codex
|
|
4142
|
-
// Round 1 (Apr 29) caught: without this branch, those sessions
|
|
4143
|
-
// showed only the seed prompt + exit marker in thread view.
|
|
4144
|
-
const line = String(data.line || '').trim();
|
|
4145
|
-
if (line) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: line.length > 320 ? line.slice(0, 320) + '…' : line }));
|
|
4146
|
-
} else if (type === 'stderr') {
|
|
4147
|
-
const line = String(data.line || '').trim();
|
|
4148
|
-
if (line) appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · stderr', time: t, content: line.length > 240 ? line.slice(0, 240) + '…' : line, avatar: '⚠' }));
|
|
4149
|
-
} else if (type === 'error') {
|
|
4150
|
-
const msg = String(data.message || data.error || 'error');
|
|
4151
|
-
appendThreadMessage(threadMessage({ cls: 'error', author: agent, time: t, content: msg }));
|
|
4152
|
-
} else if (type === 'codehere:error') {
|
|
4153
|
-
const msg = String(data.message || data.kind || 'error');
|
|
4154
|
-
appendThreadMessage(threadMessage({ cls: 'error', author: 'codehere', time: t, content: msg }));
|
|
4155
|
-
} else if (type === 'exit') {
|
|
4156
|
-
const status = String(data.status || 'completed');
|
|
4157
|
-
appendThreadMessage(threadMessage({
|
|
4158
|
-
cls: status === 'error' ? 'error' : 'agent',
|
|
4159
|
-
author: agent + ' · ' + status,
|
|
4160
|
-
time: t,
|
|
4161
|
-
content: 'session ended',
|
|
4162
|
-
avatar: status === 'error' ? '✗' : '✓',
|
|
4163
|
-
}));
|
|
4164
|
-
}
|
|
4165
|
-
}
|
|
4166
|
-
|
|
4167
|
-
// Layer 2: render the semantic step list inside the collapsed card.
|
|
4168
|
-
// Principles #1 (web is not a terminal) + #2 (info density) — show last 3
|
|
4169
|
-
// steps with pass/active/fail glyphs; hide the rest behind "+ N more" so
|
|
4170
|
-
// the card stays compact even on a 20-step task.
|
|
4171
|
-
function renderSteps(steps) {
|
|
4172
|
-
// Pattern 1 — update the tool-progress chip on every step snapshot.
|
|
4173
|
-
renderToolProgress(steps);
|
|
4174
|
-
if (!Array.isArray(steps) || steps.length === 0) {
|
|
4175
|
-
stepsEl.innerHTML = '';
|
|
4176
|
-
// Pattern 13: no steps + still running → keep whimsy rotation alive.
|
|
4177
|
-
if (session.status === 'running' && lastMode !== 'whimsy') setMicroState('whimsy');
|
|
4178
|
-
return;
|
|
4179
|
-
}
|
|
4180
|
-
const tail = steps.slice(-3);
|
|
4181
|
-
const more = steps.length - tail.length;
|
|
4182
|
-
const glyph = { done: '✓', active: '⟳', failed: '✗', pending: '·' };
|
|
4183
|
-
stepsEl.innerHTML =
|
|
4184
|
-
tail.map(s => {
|
|
4185
|
-
const st = String(s.state || 'pending');
|
|
4186
|
-
const g = glyph[st] || '·';
|
|
4187
|
-
// Pattern 2 — derive tool kind + split label so the verb-color
|
|
4188
|
-
// semantic CSS applies (read/grep muted, write/edit green, bash
|
|
4189
|
-
// peach). Falls back to a single .verb span when label is one word.
|
|
4190
|
-
// Pattern 14 — smart-truncate the target per tool kind so the
|
|
4191
|
-
// meaningful chunk survives the trace's narrow column.
|
|
4192
|
-
const kind = inferToolKind(s.label);
|
|
4193
|
-
const parts = splitStepLabel(String(s.label || ''));
|
|
4194
|
-
const truncatedTarget = truncateTarget(kind, parts.target);
|
|
4195
|
-
const labelHtml = truncatedTarget
|
|
4196
|
-
? '<span class="verb">' + escape(parts.verb) + '</span><span class="target">' + escape(truncatedTarget) + '</span>'
|
|
4197
|
-
: '<span class="verb">' + escape(parts.verb) + '</span>';
|
|
4198
|
-
// P22 / Track B #8 — bash output preview. Render last 4
|
|
4199
|
-
// lines of step.output under the step header. Only shows
|
|
4200
|
-
// for done/failed steps (active steps don't have output yet).
|
|
4201
|
-
// Note: split-on-newline uses fromCharCode(10) to dodge the
|
|
4202
|
-
// outer TS template literal eating the backslash-n.
|
|
4203
|
-
var outputHtml = '';
|
|
4204
|
-
if (s.output && (st === 'done' || st === 'failed')) {
|
|
4205
|
-
var lines = String(s.output).split(String.fromCharCode(10));
|
|
4206
|
-
var tailLines = lines.slice(-4);
|
|
4207
|
-
outputHtml = '<div class="step-output">' +
|
|
4208
|
-
tailLines.map(function(ln) { return escape(ln); }).join('<br>') +
|
|
4209
|
-
'</div>';
|
|
4210
|
-
}
|
|
4211
|
-
return '<div class="step ' + st + '" data-tool="' + escape(kind) + '"><span class="ico">' + g + '</span>' + labelHtml + outputHtml + '</div>';
|
|
4212
|
-
}).join('') +
|
|
4213
|
-
(more > 0 ? '<div class="more">+ ' + more + ' earlier</div>' : '');
|
|
4214
|
-
|
|
4215
|
-
// Pattern 13: tool-prefixed verb takes over while a step is active.
|
|
4216
|
-
// Most-recent active step's label becomes the micro-activity text;
|
|
4217
|
-
// when no step is active we fall back to whimsy rotation.
|
|
4218
|
-
// Pattern 17: 3-bucket long-op indicator — 5s timer → 15s bar → 30s
|
|
4219
|
-
// still-running + ESC hint.
|
|
4220
|
-
const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
|
|
4221
|
-
if (active && session.status === 'running') {
|
|
4222
|
-
let label = String(active.label || 'Working on it…');
|
|
4223
|
-
const startedAt = Number(active.startedAt || 0);
|
|
4224
|
-
if (startedAt > 0) {
|
|
4225
|
-
const elapsedMs = Date.now() - startedAt;
|
|
4226
|
-
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
4227
|
-
if (elapsedSec >= 5) {
|
|
4228
|
-
const timer = fmtElapsed(elapsedMs);
|
|
4229
|
-
// 15s threshold: add indeterminate progress bar
|
|
4230
|
-
if (elapsedSec >= 15) {
|
|
4231
|
-
const barFill = Math.floor((elapsedSec % 15) / 15 * 8);
|
|
4232
|
-
const barEmpty = 8 - barFill;
|
|
4233
|
-
const bar = '▮'.repeat(barFill) + '░'.repeat(barEmpty);
|
|
4234
|
-
label += ' · ' + timer + ' ' + bar;
|
|
4235
|
-
} else {
|
|
4236
|
-
// 5–15s: elapsed timer only
|
|
4237
|
-
label += ' · ' + timer;
|
|
4238
|
-
}
|
|
4239
|
-
if (elapsedSec >= 30) label += ' · still running · ESC to cancel';
|
|
4240
|
-
}
|
|
4241
|
-
}
|
|
4242
|
-
setMicroState('tool', label);
|
|
4243
|
-
} else if (session.status === 'running') {
|
|
4244
|
-
setMicroState('whimsy');
|
|
4245
|
-
} else {
|
|
4246
|
-
setMicroState('');
|
|
4247
|
-
}
|
|
4248
|
-
}
|
|
4249
|
-
|
|
4250
|
-
// Layer 3: render the final report block when the task completes.
|
|
4251
|
-
// Principles #2 + #7 — density (files/tests/trust in one view) and
|
|
4252
|
-
// agent-provenance (the report attributes to a specific agent).
|
|
4253
|
-
// Iter 12 — prose summary (3 paragraphs, plain English) prepended above
|
|
4254
|
-
// the artifact list. Non-technical users see prose first; raw artifact
|
|
4255
|
-
// detail (files/tests/tokens) below as supporting context.
|
|
4256
|
-
function renderArtifacts(artifacts, finalReport, status, completedAt) {
|
|
4257
|
-
// Show the block if either prose summary OR artifacts exist OR if a
|
|
4258
|
-
// completed task is still generating its report (placeholder state).
|
|
4259
|
-
const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
|
|
4260
|
-
const hasContent = artifacts || finalReport;
|
|
4261
|
-
const reportFailed = session.failedReport === true;
|
|
4262
|
-
// v0.6.x — bound the "Generating report…" placeholder to the actual
|
|
4263
|
-
// generation window. Final Report typically returns within 30s; after
|
|
4264
|
-
// 90s post-completion with no report, the report either failed silently
|
|
4265
|
-
// or the daemon was restarted (in-memory finalReportBySession Map gets
|
|
4266
|
-
// wiped). Either way, "Generating report…" forever is dishonest.
|
|
4267
|
-
const REPORT_WAIT_BUDGET_MS = 90_000;
|
|
4268
|
-
const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
|
|
4269
|
-
const stillWaitingForReport = isCompleted && !finalReport && !reportFailed && sinceCompleted < REPORT_WAIT_BUDGET_MS;
|
|
4270
|
-
const reportTimedOut = isCompleted && !finalReport && !reportFailed && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
|
|
4271
|
-
const isAwaitingReport = stillWaitingForReport;
|
|
4272
|
-
if (!hasContent && !isAwaitingReport && !reportTimedOut && !reportFailed) { finalReportEl.style.display = 'none'; return; }
|
|
4273
|
-
finalReportEl.style.display = 'block';
|
|
4274
|
-
const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
|
|
4275
|
-
const tests = artifacts?.tests;
|
|
4276
|
-
let html = '<div class="fr-label">Final report · ' + escape(agentLabel(session.agent)) + '</div>';
|
|
4277
|
-
if (finalReport && finalReport.summary) {
|
|
4278
|
-
// Plain-English prose. Split paragraphs on blank lines, render each as <p>.
|
|
4279
|
-
// NOTE: this code is inside a TS template literal — backslashes in
|
|
4280
|
-
// regex literals must be doubled so the served JS gets the right
|
|
4281
|
-
// sequence (test/cli/ui-served-html-syntax.test.ts guards this).
|
|
4282
|
-
const paragraphs = String(finalReport.summary).split(/\\n\\s*\\n/).filter(p => p.trim());
|
|
4283
|
-
html += '<div class="fr-summary">' +
|
|
4284
|
-
paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
|
|
4285
|
-
'</div>';
|
|
4286
|
-
} else if (isAwaitingReport) {
|
|
4287
|
-
html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
|
|
4288
|
-
} else if (reportFailed) {
|
|
4289
|
-
html += '<div class="fr-summary fr-summary-pending"><p>Final Report generation failed. The agent finished but the summary couldn\\'t be created. Re-spawn to get a fresh report.</p></div>';
|
|
4290
|
-
} else if (reportTimedOut) {
|
|
4291
|
-
// Honest message instead of perpetual "Generating report…". Most
|
|
4292
|
-
// common cause: daemon restarted (Final Report is in-memory only,
|
|
4293
|
-
// doesn't persist to disk yet — v0.6.x cleanup item). Less common:
|
|
4294
|
-
// the report provider (claude-free / claude) failed silently.
|
|
4295
|
-
html += '<div class="fr-summary fr-summary-pending"><p>Final Report unavailable for this session. (Most often this means the daemon restarted — reports aren\\'t persisted yet. Re-spawn to get a fresh one.)</p></div>';
|
|
4296
|
-
}
|
|
4297
|
-
if (files.length > 0) {
|
|
4298
|
-
// Pattern 7 — file chip with mutation tint, trust grade letter,
|
|
4299
|
-
// diff stats. Mutation kind derives from added/removed counts so
|
|
4300
|
-
// the file-icon color signals "create / edit / delete" at glance.
|
|
4301
|
-
const FILE_ICON_SVG = '<svg class="file-icon" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M3 1 L9 1 L11.5 3.5 L11.5 13 L3 13 Z"/><path d="M9 1 L9 3.5 L11.5 3.5"/></svg>';
|
|
4302
|
-
const trustGrade = (n) => n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F';
|
|
4303
|
-
html += '<div class="fr-files">' + files.map(f => {
|
|
4304
|
-
const added = Number(f.added || 0);
|
|
4305
|
-
const removed = Number(f.removed || 0);
|
|
4306
|
-
let mutation;
|
|
4307
|
-
if (added > 0 && removed === 0) mutation = 'write';
|
|
4308
|
-
else if (added === 0 && removed > 0) mutation = 'delete';
|
|
4309
|
-
else mutation = 'edit';
|
|
4310
|
-
const lines = '<span class="add">+' + added + '</span> <span class="rem">−' + removed + '</span>';
|
|
4311
|
-
// Pattern 11 — file chip uses the standalone trust-badge helper
|
|
4312
|
-
// instead of inline class-picking. Single source of truth for
|
|
4313
|
-
// grade-cutoff + color-split. (Note: this CORRECTS the prior
|
|
4314
|
-
// 60-cutoff which put D-grade trust in red; spec puts D in
|
|
4315
|
-
// warn-yellow.)
|
|
4316
|
-
return '<div class="fr-file" data-mutation="' + escape(mutation) + '">' +
|
|
4317
|
-
FILE_ICON_SVG +
|
|
4318
|
-
'<span class="path" title="' + escape(String(f.path)) + '">' + escape(String(f.path)) + '</span>' +
|
|
4319
|
-
'<span class="lines">' + lines + '</span>' +
|
|
4320
|
-
trustBadgeHTML(typeof f.trust === 'number' ? f.trust : null) +
|
|
4321
|
-
'</div>';
|
|
4322
|
-
}).join('') + '</div>';
|
|
4323
|
-
} else {
|
|
4324
|
-
html += '<div class="fr-none">No file changes detected (or not run inside a git repo).</div>';
|
|
4325
|
-
}
|
|
4326
|
-
if (tests && (tests.passed || tests.failed)) {
|
|
4327
|
-
html += '<div class="fr-tests">Tests · <span class="pass">' + (tests.passed || 0) + ' passed</span>' +
|
|
4328
|
-
(tests.failed ? ', <span class="fail">' + tests.failed + ' failed</span>' : '') +
|
|
4329
|
-
(tests.durationMs ? ' · ' + Math.round(tests.durationMs / 100) / 10 + 's' : '') + '</div>';
|
|
4330
|
-
}
|
|
4331
|
-
const u = artifacts.usage;
|
|
4332
|
-
if (u && (u.inputTokens || u.outputTokens)) {
|
|
4333
|
-
const fmt = (n) => n >= 10000 ? (Math.round(n / 100) / 10).toFixed(1) + 'k' : String(n);
|
|
4334
|
-
let tokensLine = 'Tokens · <b>' + fmt(u.inputTokens) + '</b> in · <b>' + fmt(u.outputTokens) + '</b> out';
|
|
4335
|
-
if (u.cacheReadTokens) tokensLine += ' · ' + fmt(u.cacheReadTokens) + ' cached';
|
|
4336
|
-
html += '<div class="fr-tests">' + tokensLine + '</div>';
|
|
4337
|
-
}
|
|
4338
|
-
finalReportEl.innerHTML = html;
|
|
4339
|
-
}
|
|
4340
|
-
|
|
4341
|
-
// Hydrate from initial snapshot (late-joiner support + persisted sessions)
|
|
4342
|
-
if (Array.isArray(session.steps) && session.steps.length) renderSteps(session.steps);
|
|
4343
|
-
// Render Final Report block if there's anything to show (prose summary,
|
|
4344
|
-
// artifacts, or "generating…" placeholder for a freshly-completed session).
|
|
4345
|
-
if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
|
|
4346
|
-
renderArtifacts(session.artifacts, session.finalReport, session.status, session.completedAt);
|
|
4347
|
-
}
|
|
4348
|
-
|
|
4349
|
-
// Persisted sessions have no live event stream and no subprocess to kill.
|
|
4350
|
-
if (isPersisted) {
|
|
4351
|
-
killBtn.style.display = 'none';
|
|
4352
|
-
// Use .persisted (visible, faded). Pre-fix this added .observed
|
|
4353
|
-
// which then collided with the detected-process hide rule from
|
|
4354
|
-
// claim_user_persona work — your own past sessions disappeared.
|
|
4355
|
-
card.classList.add('persisted');
|
|
4356
|
-
return;
|
|
4357
|
-
}
|
|
4358
|
-
|
|
4359
|
-
const es = new EventSource('/api/task/' + session.sessionId + '/events');
|
|
4360
|
-
es.onmessage = (ev) => {
|
|
4361
|
-
try {
|
|
4362
|
-
const event = JSON.parse(ev.data);
|
|
4363
|
-
|
|
4364
|
-
// Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
|
|
4365
|
-
if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
|
|
4366
|
-
if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
|
|
4367
|
-
|
|
4368
|
-
// Pattern 6 · codehere:error events carry a structured code that maps
|
|
4369
|
-
// 1:1 to a macro state. rate_limit / quota_exceeded / credit_low /
|
|
4370
|
-
// auth_invalid / model_unavailable each get their own pill copy +
|
|
4371
|
-
// color so users can see WHY the agent stopped, not just THAT it did.
|
|
4372
|
-
// Falls back to 'error' (red "Failed") for unknown codes.
|
|
4373
|
-
if (event.type === 'codehere:error') {
|
|
4374
|
-
var code = event.data && event.data.code;
|
|
4375
|
-
var state = code && STATE_LABELS[code] ? code : 'error';
|
|
4376
|
-
updateStatus(pillEl, state);
|
|
4377
|
-
// Pattern 9 — surface a recovery affordance below the session
|
|
4378
|
-
// header for known failure codes. Idempotent: if already shown,
|
|
4379
|
-
// skip (sessions can emit codehere:error multiple times).
|
|
4380
|
-
if (code && !card.querySelector('.recovery-affordance')) {
|
|
4381
|
-
var nextAgent = pickFailoverTarget(session.agent);
|
|
4382
|
-
var built = buildRecoveryAffordance(code, session.agent, nextAgent);
|
|
4383
|
-
// Insert between session-head and session-body so the affordance
|
|
4384
|
-
// sits at the top of the card body (most-prominent position).
|
|
4385
|
-
var bodyEl = card.querySelector('.session-body');
|
|
4386
|
-
if (bodyEl) bodyEl.parentNode.insertBefore(built.el, bodyEl);
|
|
4387
|
-
else card.appendChild(built.el);
|
|
4388
|
-
built.el.querySelector('.ra-secondary').addEventListener('click', function() {
|
|
4389
|
-
built.el.remove();
|
|
4390
|
-
});
|
|
4391
|
-
built.el.querySelector('.ra-primary').addEventListener('click', async function() {
|
|
4392
|
-
if (!built.copy.canFailover) {
|
|
4393
|
-
built.el.remove();
|
|
4394
|
-
return;
|
|
4395
|
-
}
|
|
4396
|
-
var primary = built.el.querySelector('.ra-primary');
|
|
4397
|
-
primary.disabled = true;
|
|
4398
|
-
primary.textContent = 'Switching…';
|
|
4399
|
-
try {
|
|
4400
|
-
var r = await fetch('/api/failover-accept', {
|
|
4401
|
-
method: 'POST',
|
|
4402
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4403
|
-
body: JSON.stringify({ sessionId: session.sessionId, targetAgent: nextAgent })
|
|
4404
|
-
});
|
|
4405
|
-
var d = await r.json();
|
|
4406
|
-
if (d.error || (!d.ok || !d.newSessionId)) {
|
|
4407
|
-
built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> ' + escape(d.detail || 'Target agent not ready');
|
|
4408
|
-
primary.style.display = 'none';
|
|
4409
|
-
built.el.querySelector('.ra-secondary').textContent = 'Dismiss';
|
|
4410
|
-
return;
|
|
4411
|
-
}
|
|
4412
|
-
// Session threading: add "continued as" link on the original card
|
|
4413
|
-
var continuedAsEl = document.createElement('div');
|
|
4414
|
-
continuedAsEl.className = 'session-continued-as';
|
|
4415
|
-
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(nextAgent) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
|
|
4416
|
-
// Insert after session-head
|
|
4417
|
-
var headEl = card.querySelector('.session-head');
|
|
4418
|
-
if (headEl && headEl.parentNode) headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
|
|
4419
|
-
built.el.querySelector('.ra-msg').innerHTML = '↻ Continued as <b>' + escape(nextAgent) + '</b>';
|
|
4420
|
-
primary.style.display = 'none';
|
|
4421
|
-
built.el.querySelector('.ra-secondary').textContent = 'OK';
|
|
4422
|
-
} catch {
|
|
4423
|
-
built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> Network error';
|
|
4424
|
-
primary.disabled = false;
|
|
4425
|
-
primary.textContent = 'Retry';
|
|
4426
|
-
}
|
|
4427
|
-
});
|
|
4428
|
-
}
|
|
4429
|
-
// fall through — renderEvent renders the red banner in the log
|
|
4430
|
-
}
|
|
4431
|
-
|
|
4432
|
-
// Gate B1 · tally safety events continuously (non-exclusive with render)
|
|
4433
|
-
bumpSafety(event);
|
|
4434
|
-
|
|
4435
|
-
renderEvent(logEl, event);
|
|
4436
|
-
renderEventToThread(event); // Apr 29 · Slack-shape thread v0 (Day 2)
|
|
4437
|
-
if (event.type === 'exit') {
|
|
4438
|
-
updateStatus(pillEl, event.data?.status || 'completed');
|
|
4439
|
-
killBtn.disabled = true; killBtn.style.opacity = '0.4';
|
|
4440
|
-
const dot = card.querySelector('.sub .live-dot');
|
|
4441
|
-
if (dot) dot.remove();
|
|
4442
|
-
setMicroState(''); // Pattern 13 — stop rotator on session exit
|
|
4443
|
-
stopElapsedTick(); // Pattern 1 — stop the chip's elapsed ticker
|
|
4444
|
-
stopNarrationTick(); // v0.6 M3 — stop the narrator poll on exit
|
|
4445
|
-
// Final narration fetch — backend may emit one last summary
|
|
4446
|
-
// after the exit event; give it a beat then poll once more.
|
|
4447
|
-
setTimeout(pollNarration, 2000);
|
|
4448
|
-
stopBlockTick(); // P18 slice 4 — stop the per-session block poll
|
|
4449
|
-
// Final blocks fetch — agent may have run one last hook-blocked
|
|
4450
|
-
// tool call right before exit; surface it.
|
|
4451
|
-
setTimeout(pollBlocks, 2000);
|
|
4452
|
-
// Final chip render with the terminal status so it shows the last
|
|
4453
|
-
// dot pattern + final elapsed value in the chip's complete/failed state.
|
|
4454
|
-
if (Array.isArray(session.steps)) renderToolProgress(session.steps);
|
|
4455
|
-
es.close();
|
|
4456
|
-
}
|
|
4457
|
-
if (event.type === 'error') {
|
|
4458
|
-
setMicroState(''); // Pattern 13
|
|
4459
|
-
updateStatus(pillEl, 'error');
|
|
4460
|
-
}
|
|
4461
|
-
} catch { /* skip */ }
|
|
4462
|
-
};
|
|
4463
|
-
es.onerror = () => { /* server may have closed after exit; fine */ };
|
|
4464
|
-
|
|
4465
|
-
// Pattern 19 — interrupt affordance with 2-press confirm + ESC shortcut.
|
|
4466
|
-
// First press flips the button to a "press again to confirm" red state
|
|
4467
|
-
// for 3s; second press during that window actually kills. ESC while the
|
|
4468
|
-
// card is focused triggers the same flow. Prevents accidental kills.
|
|
4469
|
-
var killConfirmTimer = null;
|
|
4470
|
-
function armKillConfirm() {
|
|
4471
|
-
killBtn.dataset.state = 'confirming';
|
|
4472
|
-
killBtn.textContent = 'Confirm?';
|
|
4473
|
-
if (killConfirmTimer) clearTimeout(killConfirmTimer);
|
|
4474
|
-
killConfirmTimer = setTimeout(function() {
|
|
4475
|
-
killBtn.dataset.state = 'ready';
|
|
4476
|
-
killBtn.textContent = 'Kill';
|
|
4477
|
-
killConfirmTimer = null;
|
|
4478
|
-
}, 3000);
|
|
4479
|
-
}
|
|
4480
|
-
async function performKill() {
|
|
4481
|
-
if (killConfirmTimer) { clearTimeout(killConfirmTimer); killConfirmTimer = null; }
|
|
4482
|
-
killBtn.disabled = true;
|
|
4483
|
-
killBtn.dataset.state = 'killing';
|
|
4484
|
-
killBtn.textContent = '…';
|
|
4485
|
-
try {
|
|
4486
|
-
await fetch('/api/task/' + session.sessionId + '/kill', { method: 'POST' });
|
|
4487
|
-
} catch { /* ignore */ }
|
|
4488
|
-
killBtn.dataset.state = 'ready';
|
|
4489
|
-
killBtn.textContent = 'Kill';
|
|
4490
|
-
killBtn.disabled = false;
|
|
4491
|
-
}
|
|
4492
|
-
killBtn.dataset.state = 'ready';
|
|
4493
|
-
killBtn.addEventListener('click', function(ev) {
|
|
4494
|
-
ev.stopPropagation();
|
|
4495
|
-
if (killBtn.dataset.state === 'confirming') {
|
|
4496
|
-
performKill();
|
|
4497
|
-
} else {
|
|
4498
|
-
armKillConfirm();
|
|
4499
|
-
}
|
|
4500
|
-
});
|
|
4501
|
-
// ESC keyboard shortcut — fires when the session card has focus within.
|
|
4502
|
-
card.addEventListener('keydown', function(ev) {
|
|
4503
|
-
if (ev.key !== 'Escape') return;
|
|
4504
|
-
if (killBtn.disabled) return;
|
|
4505
|
-
ev.preventDefault();
|
|
4506
|
-
if (killBtn.dataset.state === 'confirming') {
|
|
4507
|
-
performKill();
|
|
4508
|
-
} else {
|
|
4509
|
-
armKillConfirm();
|
|
4510
|
-
}
|
|
4511
|
-
});
|
|
4512
|
-
|
|
4513
|
-
// Iter 12 — expose renderArtifacts so the polling loop can refresh the
|
|
4514
|
-
// Final Report block when the prose summary arrives async (~2-5s after
|
|
4515
|
-
// task exit). Without this, non-technical users would have to refresh.
|
|
4516
|
-
sessions.set(session.sessionId, { card, logEl, es, renderArtifacts });
|
|
4517
|
-
}
|
|
4518
|
-
|
|
4519
|
-
async function assignTask() {
|
|
4520
|
-
const prompt = promptEl.value.trim();
|
|
4521
|
-
if (!prompt) return;
|
|
4522
|
-
// Apr 29: empty value = "Auto" option = backend defaults to conductor
|
|
4523
|
-
// (the supervisor routes). Specialists are explicit overrides.
|
|
4524
|
-
// M5 slice 2: agent === '*' = fan-out (every installed specialist in
|
|
4525
|
-
// parallel via /api/task/batch).
|
|
4526
|
-
const agent = agentSelect.value;
|
|
4527
|
-
const cwd = cwdInput.value.trim();
|
|
4528
|
-
if (agent === '*') {
|
|
4529
|
-
await assignParallel(prompt, cwd);
|
|
4530
|
-
return;
|
|
4531
|
-
}
|
|
4532
|
-
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
4533
|
-
try {
|
|
4534
|
-
const res = await fetch('/api/task', {
|
|
4535
|
-
method: 'POST',
|
|
4536
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4537
|
-
body: JSON.stringify({ prompt, agent: agent || undefined, cwd: cwd || undefined }),
|
|
4538
|
-
});
|
|
4539
|
-
const data = await res.json();
|
|
4540
|
-
if (!res.ok) {
|
|
4541
|
-
// v0.6 S3 slice 2 — friendly message on daily-budget refusal so
|
|
4542
|
-
// non-technical users see the WHY + the next move instead of a
|
|
4543
|
-
// raw error code.
|
|
4544
|
-
if (res.status === 429 && data.error === 'daily_budget_exhausted') {
|
|
4545
|
-
alert(data.message || 'Daily budget reached.');
|
|
4546
|
-
} else {
|
|
4547
|
-
alert('Failed: ' + (data.error || res.status));
|
|
4548
|
-
}
|
|
4549
|
-
return;
|
|
4550
|
-
}
|
|
4551
|
-
attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
|
|
4552
|
-
promptEl.value = '';
|
|
4553
|
-
// Spend chip refreshes on its own 60s tick; nudge it now since a
|
|
4554
|
-
// spawn means new spend is imminent.
|
|
4555
|
-
refreshSpendToday();
|
|
4556
|
-
} catch (e) {
|
|
4557
|
-
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
4558
|
-
} finally {
|
|
4559
|
-
spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
|
|
4560
|
-
}
|
|
4561
|
-
}
|
|
4562
|
-
|
|
4563
|
-
// M5 slice 2 — fan-out spawn. Sends the same prompt to every installed
|
|
4564
|
-
// ready specialist in parallel via /api/task/batch. Cap of 5 enforced
|
|
4565
|
-
// by the backend; if the user has more than 5 ready specialists we
|
|
4566
|
-
// take the first 5 in the readiness order (sort already favors ready).
|
|
4567
|
-
async function assignParallel(prompt, cwd) {
|
|
4568
|
-
const readySpecialists = (agentReadiness || [])
|
|
4569
|
-
.filter(a => a.state === 'ready')
|
|
4570
|
-
.map(a => a.id)
|
|
4571
|
-
.slice(0, 5);
|
|
4572
|
-
if (readySpecialists.length < 2) {
|
|
4573
|
-
alert('Fan-out needs at least 2 ready agents. Currently ready: ' + readySpecialists.length);
|
|
4574
|
-
return;
|
|
4575
|
-
}
|
|
4576
|
-
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
4577
|
-
try {
|
|
4578
|
-
const res = await fetch('/api/task/batch', {
|
|
4579
|
-
method: 'POST',
|
|
4580
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4581
|
-
body: JSON.stringify({
|
|
4582
|
-
tasks: readySpecialists.map(agentId => ({
|
|
4583
|
-
prompt,
|
|
4584
|
-
agent: agentId,
|
|
4585
|
-
cwd: cwd || undefined,
|
|
4586
|
-
})),
|
|
4587
|
-
}),
|
|
4588
|
-
});
|
|
4589
|
-
const data = await res.json();
|
|
4590
|
-
if (!res.ok) { alert('Batch failed: ' + (data.error || res.status)); return; }
|
|
4591
|
-
// Attach a card per spawned task. Per-task errors get surfaced
|
|
4592
|
-
// via alert listing the failed agents — successful spawns still
|
|
4593
|
-
// attach.
|
|
4594
|
-
const failed = [];
|
|
4595
|
-
for (const t of (data.tasks || [])) {
|
|
4596
|
-
if (t.sessionId) {
|
|
4597
|
-
attachSession({
|
|
4598
|
-
sessionId: t.sessionId,
|
|
4599
|
-
prompt,
|
|
4600
|
-
startedAt: t.startedAt,
|
|
4601
|
-
status: t.status,
|
|
4602
|
-
agent: t.agent,
|
|
4603
|
-
canAppendMessage: false, // batch doesn't surface this; default conservative
|
|
4604
|
-
});
|
|
4605
|
-
} else if (t.error) {
|
|
4606
|
-
failed.push(t.agent + ': ' + (t.reason || t.error));
|
|
4607
|
-
}
|
|
4608
|
-
}
|
|
4609
|
-
if (failed.length > 0) alert(failed.length + ' of ' + (data.tasks || []).length + ' fan-out spawns failed: ' + failed.join('; '));
|
|
4610
|
-
promptEl.value = '';
|
|
4611
|
-
} catch (e) {
|
|
4612
|
-
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
4613
|
-
} finally {
|
|
4614
|
-
spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
|
|
4615
|
-
}
|
|
4616
|
-
}
|
|
4617
|
-
|
|
4618
|
-
spawnBtn.addEventListener('click', assignTask);
|
|
4619
|
-
|
|
4620
|
-
// v0.6.x Slack-shape spike — Continue thread button + Cmd+Shift+Enter.
|
|
4621
|
-
// Routes the prompt as a reply to the most recent in-memory session for
|
|
4622
|
-
// the current cwd via the existing /api/task/:id/message endpoint.
|
|
4623
|
-
// Disabled when no eligible session exists; auto-refresh state every 5s
|
|
4624
|
-
// alongside the existing tasks polling.
|
|
4625
|
-
const continueBtn = document.getElementById('continue-thread');
|
|
4626
|
-
let continueTargetSessionId = null;
|
|
4627
|
-
|
|
4628
|
-
function findContinueTarget(spawnedList, cwd) {
|
|
4629
|
-
if (!cwd) return null;
|
|
4630
|
-
const eligible = (spawnedList || []).filter(s => s.cwd === cwd && s.canAppendMessage === true);
|
|
4631
|
-
if (eligible.length === 0) return null;
|
|
4632
|
-
eligible.sort((a, b) => b.startedAt - a.startedAt);
|
|
4633
|
-
return eligible[0];
|
|
4634
|
-
}
|
|
4635
|
-
|
|
4636
|
-
function refreshContinueAvailability() {
|
|
4637
|
-
const cwd = cwdInput.value.trim();
|
|
4638
|
-
if (!cwd) {
|
|
4639
|
-
continueTargetSessionId = null;
|
|
4640
|
-
continueBtn.disabled = true;
|
|
4641
|
-
continueBtn.title = 'Set a folder first to continue its thread';
|
|
4642
|
-
return;
|
|
4643
|
-
}
|
|
4644
|
-
fetch('/api/tasks').then(r => r.json()).then(d => {
|
|
4645
|
-
const target = findContinueTarget(d.spawned, cwd);
|
|
4646
|
-
if (target) {
|
|
4647
|
-
continueTargetSessionId = target.sessionId;
|
|
4648
|
-
continueBtn.disabled = false;
|
|
4649
|
-
const promptPreview = (target.prompt || '').slice(0, 40);
|
|
4650
|
-
continueBtn.title = 'Reply to last task in this folder: "' + promptPreview + (target.prompt && target.prompt.length > 40 ? '…' : '') + '" (' + target.agent + ')';
|
|
4651
|
-
} else {
|
|
4652
|
-
continueTargetSessionId = null;
|
|
4653
|
-
continueBtn.disabled = true;
|
|
4654
|
-
continueBtn.title = 'No live session for this folder. Use Assign new instead.';
|
|
4655
|
-
}
|
|
4656
|
-
}).catch(() => {});
|
|
4657
|
-
}
|
|
4658
|
-
|
|
4659
|
-
async function continueThread() {
|
|
4660
|
-
const prompt = promptEl.value.trim();
|
|
4661
|
-
if (!prompt) return;
|
|
4662
|
-
if (!continueTargetSessionId) { alert('No live session for this folder. Use Assign new instead.'); return; }
|
|
4663
|
-
const targetId = continueTargetSessionId;
|
|
4664
|
-
continueBtn.disabled = true;
|
|
4665
|
-
const originalText = continueBtn.textContent;
|
|
4666
|
-
continueBtn.textContent = '...';
|
|
4667
|
-
try {
|
|
4668
|
-
const res = await fetch('/api/task/' + encodeURIComponent(targetId) + '/message', {
|
|
4669
|
-
method: 'POST',
|
|
4670
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4671
|
-
body: JSON.stringify({ text: prompt }),
|
|
4672
|
-
});
|
|
4673
|
-
const data = await res.json();
|
|
4674
|
-
if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
|
|
4675
|
-
promptEl.value = '';
|
|
4676
|
-
// Scroll the existing card into view; existing 5s polling refreshes content.
|
|
4677
|
-
const card = document.querySelector('[data-session-id="' + targetId + '"]');
|
|
4678
|
-
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4679
|
-
} catch (e) {
|
|
4680
|
-
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
4681
|
-
} finally {
|
|
4682
|
-
continueBtn.disabled = false;
|
|
4683
|
-
continueBtn.textContent = originalText;
|
|
4684
|
-
// Re-check availability (target may have settled differently)
|
|
4685
|
-
refreshContinueAvailability();
|
|
4686
|
-
}
|
|
4687
|
-
}
|
|
4688
|
-
|
|
4689
|
-
continueBtn.addEventListener('click', continueThread);
|
|
4690
|
-
cwdInput.addEventListener('input', refreshContinueAvailability);
|
|
4691
|
-
cwdInput.addEventListener('change', refreshContinueAvailability);
|
|
4692
|
-
|
|
4693
|
-
promptEl.addEventListener('keydown', (e) => {
|
|
4694
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
4695
|
-
e.preventDefault();
|
|
4696
|
-
if (e.shiftKey) {
|
|
4697
|
-
continueThread();
|
|
4698
|
-
} else {
|
|
4699
|
-
assignTask();
|
|
4700
|
-
}
|
|
4701
|
-
}
|
|
4702
|
-
});
|
|
4703
|
-
|
|
4704
|
-
// Refresh continue-target availability alongside the existing tasks polling
|
|
4705
|
-
setInterval(refreshContinueAvailability, 5000);
|
|
4706
|
-
refreshContinueAvailability();
|
|
4707
|
-
|
|
4708
|
-
// ── Observed sessions (F1d) ──────────────────────────────────────────
|
|
4709
|
-
function shortId(id) { return String(id || '').slice(0, 8); }
|
|
4710
|
-
|
|
4711
|
-
function timeAgo(ms) {
|
|
4712
|
-
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
4713
|
-
if (s < 60) return s + 's ago';
|
|
4714
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
4715
|
-
return Math.floor(s / 3600) + 'h ago';
|
|
4716
|
-
}
|
|
4717
|
-
|
|
4718
|
-
function renderActiveAgent(a) {
|
|
4719
|
-
const existingKey = 'agent:' + a.pid;
|
|
4720
|
-
const existing = document.querySelector('[data-session-id="' + CSS.escape(existingKey) + '"]');
|
|
4721
|
-
if (existing) return;
|
|
4722
|
-
const card = document.createElement('div');
|
|
4723
|
-
card.className = 'session observed';
|
|
4724
|
-
card.dataset.sessionId = existingKey;
|
|
4725
|
-
const uptime = a.uptimeSeconds < 60
|
|
4726
|
-
? a.uptimeSeconds + 's'
|
|
4727
|
-
: a.uptimeSeconds < 3600
|
|
4728
|
-
? Math.floor(a.uptimeSeconds / 60) + 'm'
|
|
4729
|
-
: Math.floor(a.uptimeSeconds / 3600) + 'h';
|
|
4730
|
-
card.innerHTML =
|
|
4731
|
-
'<div class="session-head">' +
|
|
4732
|
-
'<div class="meta">' +
|
|
4733
|
-
'<div class="prompt">' +
|
|
4734
|
-
agentBadgeHTML(a.id || a.label) + ' ' + escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
|
|
4735
|
-
'</div>' +
|
|
4736
|
-
'<div class="sub">running ' + escape(uptime) + '</div>' +
|
|
4737
|
-
'</div>' +
|
|
4738
|
-
'<div class="session-controls">' +
|
|
4739
|
-
'<span class="status-pill observed-pill">detected</span>' +
|
|
4740
|
-
'</div>' +
|
|
4741
|
-
'</div>';
|
|
4742
|
-
sessionsEl.appendChild(card);
|
|
4743
|
-
emptyEl.style.display = 'none';
|
|
4744
|
-
}
|
|
4745
|
-
|
|
4746
|
-
function pruneStaleAgents(seenKeys) {
|
|
4747
|
-
// Remove cards for agents that have exited since the last fetch
|
|
4748
|
-
const cards = sessionsEl.querySelectorAll('.session.observed');
|
|
4749
|
-
cards.forEach(card => {
|
|
4750
|
-
const key = card.dataset.sessionId;
|
|
4751
|
-
if (key && key.startsWith('agent:') && !seenKeys.has(key)) card.remove();
|
|
4752
|
-
});
|
|
4753
|
-
}
|
|
4754
|
-
|
|
4755
|
-
// Gate B3 · audit summary row — moat made visible on the main page.
|
|
4756
|
-
// Principles #3 (status at a glance), #7 (agent-provenance), #6 (marketing voice).
|
|
4757
|
-
const auditRow = document.getElementById('audit-summary');
|
|
4758
|
-
function updateAudit(d) {
|
|
4759
|
-
if (!d) { auditRow.classList.add('empty'); return; }
|
|
4760
|
-
const hasAny = (d.actions || 0) > 0;
|
|
4761
|
-
if (!hasAny) { auditRow.classList.add('empty'); return; }
|
|
4762
|
-
auditRow.classList.remove('empty');
|
|
4763
|
-
document.getElementById('as-actions').textContent = String(d.actions || 0);
|
|
4764
|
-
// UX-simplification iter 2 (May 7): dropped writes to hidden as-blocked /
|
|
4765
|
-
// as-trust / as-agents spans — they were updated but never displayed.
|
|
4766
|
-
// The mode-indicator pill in the nav now carries the safety-posture
|
|
4767
|
-
// signal those hidden values were once meant to surface.
|
|
4768
|
-
}
|
|
4769
|
-
function refreshAudit() {
|
|
4770
|
-
fetch('/api/audit/summary').then(r => r.json()).then(updateAudit).catch(() => { /* fine */ });
|
|
4771
|
-
}
|
|
4772
|
-
refreshAudit();
|
|
4773
|
-
setInterval(refreshAudit, 30000); // audit log isn't hot; 30s is plenty
|
|
4774
|
-
|
|
4775
|
-
// Apr 29 status-banner is now the single stats-line above ("X actions
|
|
4776
|
-
// today"). UX-simplification iter 2 (May 7) dropped the hidden setText
|
|
4777
|
-
// counters + the per-second timer that updated sb-last for a value
|
|
4778
|
-
// that was never displayed. updateBanner still toggles row visibility
|
|
4779
|
-
// when there's any session activity.
|
|
4780
|
-
function updateBanner(d) {
|
|
4781
|
-
const spawned = d.spawned || d.sessions || [];
|
|
4782
|
-
const detected = (d.activeAgents || []).length;
|
|
4783
|
-
if (spawned.length + detected > 0) {
|
|
4784
|
-
auditRow.classList.remove('empty');
|
|
4785
|
-
}
|
|
4786
|
-
}
|
|
4787
|
-
|
|
4788
|
-
function loadSessions() {
|
|
4789
|
-
fetch('/api/tasks').then(r => r.json()).then(d => {
|
|
4790
|
-
for (const s of (d.spawned || d.sessions || [])) attachSession(s);
|
|
4791
|
-
// C3 · render completed sessions from prior daemon runs (read-only cards)
|
|
4792
|
-
for (const s of (d.persisted || [])) attachSession({ ...s, provenance: 'persisted' });
|
|
4793
|
-
const seen = new Set();
|
|
4794
|
-
for (const a of (d.activeAgents || [])) {
|
|
4795
|
-
seen.add('agent:' + a.pid);
|
|
4796
|
-
renderActiveAgent(a);
|
|
4797
|
-
}
|
|
4798
|
-
pruneStaleAgents(seen);
|
|
4799
|
-
updateBanner(d);
|
|
4800
|
-
updateShowMoreButton();
|
|
4801
|
-
updateStopAllButton();
|
|
4802
|
-
}).catch(() => { /* fine */ });
|
|
4803
|
-
}
|
|
4804
|
-
// claim_user_persona — show "Show all sessions (N more)" button only
|
|
4805
|
-
// when there are more than 5 spawned sessions. Detected-process rows
|
|
4806
|
-
// don't count (they're already hidden by default).
|
|
4807
|
-
function updateShowMoreButton() {
|
|
4808
|
-
const btn = document.getElementById('show-more-sessions');
|
|
4809
|
-
if (!btn) return;
|
|
4810
|
-
const allSessions = sessionsEl.querySelectorAll('.session:not(.observed)');
|
|
4811
|
-
if (allSessions.length > 5 && !document.body.classList.contains('show-all-sessions')) {
|
|
4812
|
-
btn.style.display = 'block';
|
|
4813
|
-
btn.textContent = 'Show all sessions (' + (allSessions.length - 5) + ' more)';
|
|
4814
|
-
} else {
|
|
4815
|
-
btn.style.display = 'none';
|
|
4816
|
-
}
|
|
4817
|
-
}
|
|
4818
|
-
const showMoreBtn = document.getElementById('show-more-sessions');
|
|
4819
|
-
if (showMoreBtn) showMoreBtn.addEventListener('click', function() {
|
|
4820
|
-
document.body.classList.add('show-all-sessions');
|
|
4821
|
-
updateShowMoreButton();
|
|
4822
|
-
});
|
|
4823
|
-
|
|
4824
|
-
// Stop all running sessions — one click kills every active session.
|
|
4825
|
-
const stopAllBtn = document.getElementById('stop-all-sessions');
|
|
4826
|
-
function updateStopAllButton() {
|
|
4827
|
-
if (!stopAllBtn) return;
|
|
4828
|
-
const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
|
|
4829
|
-
stopAllBtn.style.display = runningCards.length > 1 ? 'block' : 'none';
|
|
4830
|
-
stopAllBtn.textContent = 'Stop all running (' + runningCards.length + ')';
|
|
4831
|
-
}
|
|
4832
|
-
if (stopAllBtn) stopAllBtn.addEventListener('click', function() {
|
|
4833
|
-
if (!confirm('Stop all running sessions?')) return;
|
|
4834
|
-
const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
|
|
4835
|
-
runningCards.forEach(function(card) {
|
|
4836
|
-
const killBtn = card.querySelector('.session-controls button.kill');
|
|
4837
|
-
if (killBtn && !killBtn.disabled) killBtn.click();
|
|
4838
|
-
});
|
|
4839
|
-
updateStopAllButton();
|
|
4840
|
-
});
|
|
4841
|
-
loadSessions();
|
|
4842
|
-
// Refresh detected-agents list + banner every 5s. Iter 12 — also patches
|
|
4843
|
-
// existing session cards with new finalReport prose when it arrives async.
|
|
4844
|
-
// Cheap re-render (innerHTML on a small div); non-technical user sees the
|
|
4845
|
-
// report appear on its own, no manual refresh.
|
|
4846
|
-
setInterval(() => {
|
|
4847
|
-
fetch('/api/tasks').then(r => r.json()).then(d => {
|
|
4848
|
-
const seen = new Set();
|
|
4849
|
-
for (const a of (d.activeAgents || [])) {
|
|
4850
|
-
seen.add('agent:' + a.pid);
|
|
4851
|
-
renderActiveAgent(a);
|
|
4852
|
-
}
|
|
4853
|
-
pruneStaleAgents(seen);
|
|
4854
|
-
updateBanner(d);
|
|
4855
|
-
// Patch existing session cards with finalReport when it arrives.
|
|
4856
|
-
for (const s of (d.spawned || d.sessions || [])) {
|
|
4857
|
-
const entry = sessions.get(s.sessionId);
|
|
4858
|
-
if (entry && entry.renderArtifacts) {
|
|
4859
|
-
entry.renderArtifacts(s.artifacts, s.finalReport, s.status, s.completedAt);
|
|
4860
|
-
}
|
|
4861
|
-
}
|
|
4862
|
-
}).catch(() => { /* fine */ });
|
|
4863
|
-
}, 5000);
|
|
4864
|
-
|
|
4865
|
-
// ── F1a readiness banner ─────────────────────────────────────────────
|
|
4866
|
-
const readinessEl = document.getElementById('readiness');
|
|
4867
|
-
|
|
4868
|
-
function renderReadiness(report) {
|
|
4869
|
-
// Hide the banner unless the state is genuinely actionable. May 5 2026
|
|
4870
|
-
// design pass: the prior api_key_shadow state surfaced a yellow warning
|
|
4871
|
-
// banner with shell-grep jargon for an environment condition that ISNT
|
|
4872
|
-
// a problem -- codehere already uses subscription auth and ignores the
|
|
4873
|
-
// ANTHROPIC_API_KEY env var by design. Per claim_user_persona (HIGH
|
|
4874
|
-
// non-technical-first) + the Open-Agents production-grade design
|
|
4875
|
-
// reference, only show the banner when the user can / must act on it
|
|
4876
|
-
// (needs_install, needs_auth, billed_key_required). Other states stay
|
|
4877
|
-
// observable in /api/readiness for tooling.
|
|
4878
|
-
const ACTIONABLE_STATES = new Set(['needs_install', 'needs_auth', 'billed_key_required']);
|
|
4879
|
-
if (!report || !ACTIONABLE_STATES.has(report.state)) {
|
|
4880
|
-
readinessEl.style.display = 'none';
|
|
4881
|
-
return;
|
|
4882
|
-
}
|
|
4883
|
-
readinessEl.style.display = 'block';
|
|
4884
|
-
readinessEl.setAttribute('data-severity', report.severity);
|
|
4885
|
-
let actionHtml = '';
|
|
4886
|
-
if (report.action) {
|
|
4887
|
-
if (report.action.command) {
|
|
4888
|
-
actionHtml += '<code>' + escape(report.action.command) + '</code>';
|
|
4889
|
-
}
|
|
4890
|
-
if (report.action.url) {
|
|
4891
|
-
actionHtml += ' <a href="' + escape(report.action.url) + '" target="_blank" rel="noreferrer">' + escape(report.action.label || 'Open') + ' →</a>';
|
|
4892
|
-
}
|
|
4893
|
-
}
|
|
4894
|
-
actionHtml += ' <button id="readiness-refresh">Recheck</button>';
|
|
4895
|
-
readinessEl.innerHTML =
|
|
4896
|
-
'<div class="r-title">' + escape(report.title) + '</div>' +
|
|
4897
|
-
'<div class="r-detail">' + escape(report.detail) + '</div>' +
|
|
4898
|
-
'<div class="r-action">' + actionHtml + '</div>';
|
|
4899
|
-
const btn = document.getElementById('readiness-refresh');
|
|
4900
|
-
if (btn) btn.addEventListener('click', checkReadiness);
|
|
4901
|
-
}
|
|
4902
|
-
|
|
4903
|
-
function checkReadiness() {
|
|
4904
|
-
fetch('/api/readiness').then(r => r.json()).then(renderReadiness).catch(() => {
|
|
4905
|
-
readinessEl.style.display = 'none';
|
|
4906
|
-
});
|
|
4907
|
-
}
|
|
4908
|
-
|
|
4909
|
-
checkReadiness();
|
|
4910
|
-
|
|
4911
|
-
// ── Failover offer banner (P0) ──────────────────────────────────
|
|
4912
|
-
// Polls /api/failover-offers every 5s. When offers exist, shows a
|
|
4913
|
-
// banner: "Claude rate-limited — switch to Codex?" with one-click
|
|
4914
|
-
// accept (re-spawns on the target agent) and dismiss buttons.
|
|
4915
|
-
const fbEl = document.getElementById('failover-banner');
|
|
4916
|
-
let fbDismissed = false;
|
|
4917
|
-
|
|
4918
|
-
function renderFailoverOffers(offers) {
|
|
4919
|
-
if (!offers || offers.length === 0 || fbDismissed) {
|
|
4920
|
-
fbEl.style.display = 'none';
|
|
4921
|
-
return;
|
|
4922
|
-
}
|
|
4923
|
-
fbEl.style.display = 'flex';
|
|
4924
|
-
const parts = offers.map(function(o) {
|
|
4925
|
-
var msg = '<b>' + escape(o.signal.fromAgent) + '</b> hit ' + escape(o.signal.errorCode) +
|
|
4926
|
-
' — switch to <b>' + escape(o.target) + '</b>?';
|
|
4927
|
-
var actions = '<div class="fb-actions">' +
|
|
4928
|
-
'<button class="fb-accept" data-session="' + escape(o.sessionId) + '" data-target="' + escape(o.target) + '">Switch</button>' +
|
|
4929
|
-
'<button class="fb-dismiss" data-session="' + escape(o.sessionId) + '">Dismiss</button>' +
|
|
4930
|
-
'</div>';
|
|
4931
|
-
return '<div class="fb-msg">' + msg + '</div>' + actions;
|
|
4932
|
-
});
|
|
4933
|
-
fbEl.innerHTML = parts.join('');
|
|
4934
|
-
|
|
4935
|
-
// Wire accept buttons
|
|
4936
|
-
fbEl.querySelectorAll('.fb-accept').forEach(function(btn) {
|
|
4937
|
-
btn.addEventListener('click', function() {
|
|
4938
|
-
var sessionId = btn.dataset.session;
|
|
4939
|
-
var target = btn.dataset.target;
|
|
4940
|
-
btn.disabled = true;
|
|
4941
|
-
btn.textContent = 'Switching…';
|
|
4942
|
-
fetch('/api/failover-accept', {
|
|
4943
|
-
method: 'POST',
|
|
4944
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4945
|
-
body: JSON.stringify({ sessionId: sessionId, targetAgent: target }),
|
|
4946
|
-
}).then(function(r) { return r.json(); }).then(function(d) {
|
|
4947
|
-
if (d.error) {
|
|
4948
|
-
fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> ' + escape(d.error) + (d.detail ? ' — ' + escape(d.detail) : '') + '</div>';
|
|
4949
|
-
return;
|
|
4950
|
-
}
|
|
4951
|
-
if (!d.ok || !d.newSessionId) {
|
|
4952
|
-
fbEl.innerHTML = '<div class="fb-msg"><b>Failover unavailable:</b> ' + escape(d.detail || 'Target agent not ready') + '</div>';
|
|
4953
|
-
return;
|
|
4954
|
-
}
|
|
4955
|
-
// Session threading: add "continued as" link on the original card
|
|
4956
|
-
var origCard = document.querySelector('[data-session-id="' + escape(sessionId) + '"]');
|
|
4957
|
-
if (origCard && !origCard.querySelector('.session-continued-as')) {
|
|
4958
|
-
var headEl = origCard.querySelector('.session-head');
|
|
4959
|
-
if (headEl && headEl.parentNode) {
|
|
4960
|
-
var continuedAsEl = document.createElement('div');
|
|
4961
|
-
continuedAsEl.className = 'session-continued-as';
|
|
4962
|
-
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(target) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
|
|
4963
|
-
headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
|
|
4964
|
-
}
|
|
4965
|
-
}
|
|
4966
|
-
fbEl.innerHTML = '<div class="fb-msg">↻ Switched to <b>' + escape(target) + '</b>. New session: <b>' + escape(d.newSessionId.slice(0, 8)) + '</b></div>' +
|
|
4967
|
-
'<div class="fb-actions"><button class="fb-dismiss" id="fb-success-dismiss">OK</button></div>';
|
|
4968
|
-
document.getElementById('fb-success-dismiss').addEventListener('click', function() {
|
|
4969
|
-
fbDismissed = true;
|
|
4970
|
-
fbEl.style.display = 'none';
|
|
4971
|
-
});
|
|
4972
|
-
}).catch(function() {
|
|
4973
|
-
fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> Network error. Check if the daemon is running.</div>';
|
|
4974
|
-
});
|
|
4975
|
-
});
|
|
4976
|
-
});
|
|
4977
|
-
|
|
4978
|
-
// Wire dismiss buttons (initial render)
|
|
4979
|
-
fbEl.querySelectorAll('.fb-dismiss').forEach(function(btn) {
|
|
4980
|
-
btn.addEventListener('click', function() {
|
|
4981
|
-
fbDismissed = true;
|
|
4982
|
-
fbEl.style.display = 'none';
|
|
4983
|
-
});
|
|
4984
|
-
});
|
|
4985
|
-
}
|
|
4986
|
-
|
|
4987
|
-
setInterval(function() {
|
|
4988
|
-
fetch('/api/failover-offers').then(function(r) { return r.json(); }).then(function(d) {
|
|
4989
|
-
renderFailoverOffers(d.offers);
|
|
4990
|
-
}).catch(function() { /* fine */ });
|
|
4991
|
-
}, 5000);
|
|
4992
|
-
})();
|
|
4993
|
-
</script>
|
|
4994
|
-
</body>
|
|
4995
|
-
</html>`;
|
|
4996
1709
|
/**
|
|
4997
1710
|
* Project thread view — v0.6.0 M1 phase 1.
|
|
4998
1711
|
*
|