codehere 0.8.0 → 1.0.2
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 +37 -184
- package/dist/application/use-cases/check-claimed-tests.js +81 -0
- package/dist/application/use-cases/detect-test-weakening.js +237 -0
- package/dist/application/use-cases/rerun-tests.js +329 -0
- package/dist/index.js +74 -1004
- package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -265
- package/dist/presentation/cli/commands/check-command.js +251 -0
- package/dist/presentation/cli/commands/help-command.js +22 -286
- package/dist/ui.js +19 -438
- package/dist/utils/version.js +0 -12
- package/package.json +18 -27
- package/dist/application/use-cases/domain-trust.js +0 -110
- package/dist/application/use-cases/generate-final-report.js +0 -134
- package/dist/application/use-cases/generate-narration.js +0 -128
- package/dist/application/use-cases/generate-thread-title.js +0 -178
- package/dist/application/use-cases/narrator-prompt-template.js +0 -158
- package/dist/application/use-cases/thread-export.js +0 -155
- package/dist/application/use-cases/verify-claim.js +0 -274
- package/dist/application/use-cases/write-pass.js +0 -277
- package/dist/domain/entities/task.js +0 -61
- package/dist/domain/services/codebase-detector.js +0 -91
- package/dist/error-handler.js +0 -251
- package/dist/infrastructure/completion/bash-completion.js +0 -25
- package/dist/infrastructure/completion/fish-completion.js +0 -22
- package/dist/infrastructure/completion/zsh-completion.js +0 -30
- package/dist/infrastructure/config/config-manager.js +0 -173
- package/dist/infrastructure/config/product-context.js +0 -67
- package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
- package/dist/infrastructure/context/adapters/figma-source.js +0 -163
- package/dist/infrastructure/context/adapters/github-issue-source.js +0 -159
- package/dist/infrastructure/context/adapters/linear-source.js +0 -190
- package/dist/infrastructure/context/context-resolver.js +0 -144
- package/dist/infrastructure/context/context-source.js +0 -114
- package/dist/infrastructure/context/oauth-flow.js +0 -207
- package/dist/infrastructure/context/oauth-store.js +0 -119
- package/dist/infrastructure/context/registry-bootstrap.js +0 -99
- package/dist/infrastructure/context/write-pass-resolver.js +0 -106
- package/dist/infrastructure/cost/budget-config.js +0 -113
- package/dist/infrastructure/cost/cost-router.js +0 -104
- package/dist/infrastructure/cost/spend-aggregator.js +0 -147
- package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -888
- package/dist/infrastructure/hooks/hook-mode.js +0 -71
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -43
- package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
- package/dist/infrastructure/hooks/session-analyzer.js +0 -177
- package/dist/infrastructure/observability/execution-tracer.js +0 -227
- package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
- package/dist/infrastructure/orchestration/agent-installer.js +0 -101
- package/dist/infrastructure/orchestration/agents-config-seed.js +0 -56
- package/dist/infrastructure/orchestration/aider-adapter.js +0 -447
- package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
- package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -478
- package/dist/infrastructure/orchestration/codex-adapter.js +0 -538
- package/dist/infrastructure/orchestration/conductor-adapter.js +0 -377
- package/dist/infrastructure/orchestration/context-handoff.js +0 -144
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
- package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
- package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
- package/dist/infrastructure/orchestration/failover-config.js +0 -77
- package/dist/infrastructure/orchestration/failover-policy.js +0 -106
- package/dist/infrastructure/orchestration/gemini-adapter.js +0 -583
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -558
- package/dist/infrastructure/orchestration/graphify-context.js +0 -254
- package/dist/infrastructure/orchestration/memory-injector.js +0 -380
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -233
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
- package/dist/infrastructure/orchestration/opencode-adapter.js +0 -510
- package/dist/infrastructure/orchestration/progress-aggregator.js +0 -689
- package/dist/infrastructure/orchestration/readiness.js +0 -103
- package/dist/infrastructure/orchestration/session-manager.js +0 -1091
- package/dist/infrastructure/orchestration/session-persistence.js +0 -258
- package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -46
- package/dist/infrastructure/protocols/cloud-sync.js +0 -207
- package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
- package/dist/infrastructure/protocols/mcp-server-impl.js +0 -2853
- package/dist/infrastructure/protocols/probe-port.js +0 -36
- package/dist/infrastructure/protocols/thread-phase.js +0 -105
- package/dist/infrastructure/protocols/ui-page-v2.js +0 -4418
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/protocols/verify-page.js +0 -983
- package/dist/infrastructure/security/file-collection.js +0 -118
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/security/permissions-config.js +0 -234
- package/dist/infrastructure/security/trust-scorer.js +0 -657
- package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/project-thread-migration.js +0 -237
- package/dist/infrastructure/storage/project-thread-mirror.js +0 -524
- package/dist/infrastructure/storage/project-thread-reader.js +0 -172
- package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
- package/dist/infrastructure/storage/task-repository.js +0 -206
- package/dist/monitoring.js +0 -212
- package/dist/presentation/cli/commands/audit-command.js +0 -126
- package/dist/presentation/cli/commands/budget-command.js +0 -79
- package/dist/presentation/cli/commands/config-command.js +0 -125
- package/dist/presentation/cli/commands/context-source-command.js +0 -198
- package/dist/presentation/cli/commands/hook-command.js +0 -265
- package/dist/presentation/cli/commands/init-command.js +0 -578
- package/dist/presentation/cli/commands/memory-command.js +0 -1031
- package/dist/presentation/cli/commands/narrator-command.js +0 -101
- package/dist/presentation/cli/commands/permission-command.js +0 -116
- package/dist/presentation/cli/commands/protocols-builder.js +0 -254
- package/dist/presentation/cli/commands/protocols-command.js +0 -84
- package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
- package/dist/presentation/cli/commands/report-command.js +0 -387
- package/dist/presentation/cli/commands/scan-tool-command.js +0 -94
- package/dist/presentation/cli/commands/setup-command.js +0 -307
- package/dist/presentation/cli/commands/status-command.js +0 -661
- package/dist/presentation/cli/commands/thread-command.js +0 -288
- package/dist/presentation/cli/commands/trust-command.js +0 -306
- package/dist/presentation/cli/commands/verify-command.js +0 -233
- package/dist/presentation/cli/error-display.js +0 -336
- package/dist/presentation/cli/repl.js +0 -797
- package/dist/presentation/cli/task-deliverable.js +0 -127
- package/dist/testing/cassette.js +0 -125
- package/dist/testing/replay-adapter.js +0 -166
|
@@ -1,2377 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* codehere web UI — two pages, one design system.
|
|
3
|
-
*
|
|
4
|
-
* / → TASK_HTML (v0.3: task intake + active session view)
|
|
5
|
-
* /history → HISTORY_HTML (audit feed across all agents — what I built Day 0)
|
|
6
|
-
*
|
|
7
|
-
* Plain JS, no framework. Both pages share the codehere.uk design system,
|
|
8
|
-
* centralized in SHARED_STYLES below — sourced from the actual
|
|
9
|
-
* ~/code/codehere-landing/app/globals.css (not guessed): HSL tokens matching
|
|
10
|
-
* the marketing site, keyframes (shimmer, badge-glow, beam-pulse, fade-up),
|
|
11
|
-
* and utility classes (.glass-card, .gradient-text-shimmer, .glow-line,
|
|
12
|
-
* .hero-beam).
|
|
13
|
-
*/
|
|
14
|
-
const SHARED_STYLES = `
|
|
15
|
-
:root {
|
|
16
|
-
/* HSL bg/fg matching landing globals.css dark palette */
|
|
17
|
-
--bg: hsl(0 0% 3.9%);
|
|
18
|
-
--bg-1: hsl(0 0% 6%);
|
|
19
|
-
--bg-2: hsl(0 0% 9%);
|
|
20
|
-
--fg: hsl(0 0% 98%);
|
|
21
|
-
--fg-70: rgba(255,255,255,0.70);
|
|
22
|
-
--fg-50: rgba(255,255,255,0.50);
|
|
23
|
-
--fg-40: rgba(255,255,255,0.40);
|
|
24
|
-
--fg-30: rgba(255,255,255,0.30);
|
|
25
|
-
--fg-20: rgba(255,255,255,0.20);
|
|
26
|
-
--border: rgba(255,255,255,0.10);
|
|
27
|
-
--border-soft: rgba(255,255,255,0.06);
|
|
28
|
-
--accent: #ff9a76;
|
|
29
|
-
--accent-blue: #4a90e2;
|
|
30
|
-
--accent-wash: rgba(255,154,118,0.10);
|
|
31
|
-
--allow: #28c840;
|
|
32
|
-
--block: #ff5f57;
|
|
33
|
-
--warn: #febc2e;
|
|
34
|
-
/* Per-agent identity slots — Pattern 10, Real-Q1 LOCKED 2026-05-06.
|
|
35
|
-
Codehere-only earth-tone palette. NOT vendor-influenced.
|
|
36
|
-
CVD-verified (Brettel-Viénot-Mollon): closest pair distance 45.1
|
|
37
|
-
under deuteranopia, 47.4 under protanopia. Slots are owned;
|
|
38
|
-
agent→slot mapping is metadata. Per docs/design/AGENTIC_UX.md §3. */
|
|
39
|
-
--agent-1: #a04a30; /* deep-coral → claude-code */
|
|
40
|
-
--agent-2: #d4a040; /* amber → codex */
|
|
41
|
-
--agent-3: #5a7a9b; /* slate-blue → aider */
|
|
42
|
-
--agent-4: #7a4a6c; /* plum → gemini */
|
|
43
|
-
--agent-5: #9aa550; /* olive → opencode */
|
|
44
|
-
--agent-unknown: rgba(255,255,255,0.18);
|
|
45
|
-
--radius: 8px;
|
|
46
|
-
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
47
|
-
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/* Keyframes — Tier 1 polish #4 (May 6 2026): removed shimmer + beam-pulse
|
|
51
|
-
keyframes. Their consumers (.gradient-text-shimmer, .hero-beam) were
|
|
52
|
-
stripped from the dashboard surface per Open-Agents production-grade
|
|
53
|
-
reference + PRD §8.2 permanent-anti-patterns rule. fade-up + badge-glow
|
|
54
|
-
remain (still used by session card animations + running-state pill). */
|
|
55
|
-
@keyframes badge-glow {
|
|
56
|
-
0%, 100% { box-shadow: 0 0 0 rgba(40,200,64,0); }
|
|
57
|
-
50% { box-shadow: 0 0 10px rgba(40,200,64,0.5); }
|
|
58
|
-
}
|
|
59
|
-
@keyframes fade-up {
|
|
60
|
-
from { opacity: 0; transform: translateY(12px); filter: blur(2px); }
|
|
61
|
-
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/* Glass card — landing's .glass-card with hover lift */
|
|
65
|
-
.glass-card {
|
|
66
|
-
background: rgba(255,255,255,0.02);
|
|
67
|
-
backdrop-filter: blur(16px);
|
|
68
|
-
-webkit-backdrop-filter: blur(16px);
|
|
69
|
-
border: 1px solid var(--border);
|
|
70
|
-
transition: border-color 0.3s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1), box-shadow 0.3s ease;
|
|
71
|
-
}
|
|
72
|
-
.glass-card:hover {
|
|
73
|
-
border-color: rgba(255,255,255,0.15);
|
|
74
|
-
transform: translateY(-2px);
|
|
75
|
-
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.04);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/* Horizontal divider — peach→transparent gradient */
|
|
79
|
-
.glow-line {
|
|
80
|
-
height: 1px;
|
|
81
|
-
background: linear-gradient(90deg, transparent 0%, rgba(255,154,118,0.25) 50%, transparent 100%);
|
|
82
|
-
margin: 18px 0;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/* ── Per-agent identity badge — Pattern 10 ─────────────────────────────────
|
|
86
|
-
Foundation primitive for multi-vendor visibility. 18×18 squircle with
|
|
87
|
-
2-letter initials, vendor color from --agent-N slot. 6px dot variant
|
|
88
|
-
for sidebar presence. Per docs/design/AGENTIC_UX.md §3 + Pattern 10.
|
|
89
|
-
Real-Q1 LOCKED: codehere-only palette; no vendor brand colors. */
|
|
90
|
-
/* Pattern 10 — codehere-styled badge frame. 22×22 squircle, codehere's
|
|
91
|
-
slot palette as background, geometric mark inside via currentColor.
|
|
92
|
-
Frame is codehere's identity; mark is a distinctive geometry per agent. */
|
|
93
|
-
.agent-badge {
|
|
94
|
-
display: inline-flex;
|
|
95
|
-
align-items: center;
|
|
96
|
-
justify-content: center;
|
|
97
|
-
width: 22px; height: 22px;
|
|
98
|
-
border-radius: 5px;
|
|
99
|
-
overflow: hidden;
|
|
100
|
-
color: rgba(255,255,255,0.95);
|
|
101
|
-
flex: 0 0 22px;
|
|
102
|
-
user-select: none;
|
|
103
|
-
background: var(--agent-unknown);
|
|
104
|
-
}
|
|
105
|
-
.agent-badge svg { display: block; width: 14px; height: 14px; }
|
|
106
|
-
.agent-badge[data-slot="0"] { background: var(--accent); color: rgba(0,0,0,0.85); }
|
|
107
|
-
.agent-badge[data-slot="1"] { background: var(--agent-1); }
|
|
108
|
-
.agent-badge[data-slot="2"] { background: var(--agent-2); color: rgba(0,0,0,0.85); }
|
|
109
|
-
.agent-badge[data-slot="3"] { background: var(--agent-3); }
|
|
110
|
-
.agent-badge[data-slot="4"] { background: var(--agent-4); }
|
|
111
|
-
.agent-badge[data-slot="5"] { background: var(--agent-5); color: rgba(0,0,0,0.85); }
|
|
112
|
-
.agent-badge[data-slot=""],
|
|
113
|
-
.agent-badge[data-slot="unknown"],
|
|
114
|
-
.agent-badge:not([data-slot]) { background: var(--agent-unknown); color: var(--fg-50); }
|
|
115
|
-
|
|
116
|
-
/* 6px presence-dot variant for sidebar — same slot tokens, no text */
|
|
117
|
-
.agent-presence-dot {
|
|
118
|
-
display: inline-block;
|
|
119
|
-
width: 6px; height: 6px;
|
|
120
|
-
border-radius: 50%;
|
|
121
|
-
flex: 0 0 6px;
|
|
122
|
-
vertical-align: middle;
|
|
123
|
-
}
|
|
124
|
-
.agent-presence-dot[data-slot="1"] { background: var(--agent-1); }
|
|
125
|
-
.agent-presence-dot[data-slot="2"] { background: var(--agent-2); }
|
|
126
|
-
.agent-presence-dot[data-slot="3"] { background: var(--agent-3); }
|
|
127
|
-
.agent-presence-dot[data-slot="4"] { background: var(--agent-4); }
|
|
128
|
-
.agent-presence-dot[data-slot="5"] { background: var(--agent-5); }
|
|
129
|
-
.agent-presence-dot[data-slot=""],
|
|
130
|
-
.agent-presence-dot[data-slot="unknown"],
|
|
131
|
-
.agent-presence-dot:not([data-slot]) { background: var(--agent-unknown); }
|
|
132
|
-
|
|
133
|
-
/* Pattern 4 — sidebar presence-stack. Up to 3 vendor dots per project,
|
|
134
|
-
6px each, overlapping by 3px so a stack of 3 is ~12px wide. Most-
|
|
135
|
-
recent agent renders ON TOP (last in DOM = highest z via flex order
|
|
136
|
-
reverse). Empty stack reserves the same width to prevent layout
|
|
137
|
-
shift between projects with vs without recent activity. */
|
|
138
|
-
.presence-stack {
|
|
139
|
-
display: inline-flex;
|
|
140
|
-
flex-direction: row-reverse; /* most-recent on top */
|
|
141
|
-
align-items: center;
|
|
142
|
-
margin-right: 6px;
|
|
143
|
-
min-width: 12px;
|
|
144
|
-
flex-shrink: 0;
|
|
145
|
-
}
|
|
146
|
-
.presence-stack .agent-presence-dot {
|
|
147
|
-
margin-left: -3px; /* overlap */
|
|
148
|
-
border: 1.5px solid var(--bg-1, #0a0a0a); /* halo against sidebar bg */
|
|
149
|
-
box-sizing: content-box;
|
|
150
|
-
}
|
|
151
|
-
.presence-stack .agent-presence-dot:first-child {
|
|
152
|
-
/* No left-overlap on the leftmost (= oldest) dot — its left edge sets
|
|
153
|
-
the stack's start. Pre-fix it was clipped by the negative margin. */
|
|
154
|
-
margin-left: 0;
|
|
155
|
-
}
|
|
156
|
-
.presence-stack-empty { width: 12px; }
|
|
157
|
-
|
|
158
|
-
/* Pattern 13 — micro-activity verb (rotating whimsy / tool-prefixed verb).
|
|
159
|
-
Sits below the agent byline on a running session; tells the user the
|
|
160
|
-
agent is actively working. Hidden when status != running. */
|
|
161
|
-
.micro-activity {
|
|
162
|
-
font-family: var(--mono); font-size: 11px;
|
|
163
|
-
color: var(--fg-50);
|
|
164
|
-
margin-top: 4px;
|
|
165
|
-
opacity: 0;
|
|
166
|
-
transition: opacity 200ms ease, color 200ms ease;
|
|
167
|
-
min-height: 14px;
|
|
168
|
-
}
|
|
169
|
-
.micro-activity.live { opacity: 1; }
|
|
170
|
-
.micro-activity.tool { color: var(--accent); font-weight: 500; }
|
|
171
|
-
|
|
172
|
-
/* Pattern 2 — tool-use trace verb-color semantic. The first word of each
|
|
173
|
-
step's label is the tool kind; the rule colors it per semantic meaning:
|
|
174
|
-
read/grep/glob = muted (no mutation), write/edit = green (file change),
|
|
175
|
-
bash = brand peach (execution = highest-trust action). Same target
|
|
176
|
-
text stays muted so the verb pops at glance. */
|
|
177
|
-
.steps-mini .step .verb {
|
|
178
|
-
font-weight: 500;
|
|
179
|
-
margin-right: 4px;
|
|
180
|
-
text-transform: lowercase;
|
|
181
|
-
letter-spacing: 0.02em;
|
|
182
|
-
}
|
|
183
|
-
.steps-mini .step[data-tool="read"] .verb,
|
|
184
|
-
.steps-mini .step[data-tool="grep"] .verb,
|
|
185
|
-
.steps-mini .step[data-tool="glob"] .verb,
|
|
186
|
-
.steps-mini .step[data-tool="list"] .verb { color: var(--fg-50); }
|
|
187
|
-
.steps-mini .step[data-tool="write"] .verb,
|
|
188
|
-
.steps-mini .step[data-tool="edit"] .verb { color: var(--allow); }
|
|
189
|
-
.steps-mini .step[data-tool="bash"] .verb { color: var(--accent); }
|
|
190
|
-
|
|
191
|
-
/* P22 / Track B #8 — bash output preview under step. Last 4 lines,
|
|
192
|
-
muted body so the step label stays primary. ANSI is stripped at
|
|
193
|
-
the aggregator. */
|
|
194
|
-
.steps-mini .step .step-output {
|
|
195
|
-
margin: 4px 0 6px 22px;
|
|
196
|
-
padding: 6px 8px;
|
|
197
|
-
border-left: 2px solid var(--border-soft);
|
|
198
|
-
color: var(--fg-50);
|
|
199
|
-
font-family: var(--mono); font-size: 10.5px;
|
|
200
|
-
line-height: 1.45;
|
|
201
|
-
white-space: pre-wrap; word-break: break-word;
|
|
202
|
-
background: rgba(0,0,0,0.15);
|
|
203
|
-
border-radius: 0 4px 4px 0;
|
|
204
|
-
max-height: 80px; overflow: hidden;
|
|
205
|
-
}
|
|
206
|
-
.steps-mini .step.failed .step-output {
|
|
207
|
-
border-left-color: rgba(255,95,87,0.4);
|
|
208
|
-
color: var(--block);
|
|
209
|
-
}
|
|
210
|
-
.steps-mini .step[data-tool="fetch"] .verb,
|
|
211
|
-
.steps-mini .step[data-tool="search"] .verb { color: var(--fg-50); }
|
|
212
|
-
.steps-mini .step[data-tool="task"] .verb { color: var(--accent); }
|
|
213
|
-
/* Pattern 15 — subagent dispatch indicator. Task-kind steps render as
|
|
214
|
-
a small panel with an indented left bar so the dispatch reads as
|
|
215
|
-
"delegating to a sub-agent" rather than a normal step. The label
|
|
216
|
-
keeps the verb-color but the row gets visual weight to signal a
|
|
217
|
-
boundary crossing (parent → child execution context). */
|
|
218
|
-
.steps-mini .step[data-tool="task"] {
|
|
219
|
-
border-left: 2px solid var(--accent);
|
|
220
|
-
background: var(--accent-wash);
|
|
221
|
-
padding: 4px 8px;
|
|
222
|
-
margin: 2px 0 2px 4px;
|
|
223
|
-
border-radius: 0 4px 4px 0;
|
|
224
|
-
align-items: center;
|
|
225
|
-
}
|
|
226
|
-
.steps-mini .step[data-tool="task"] .verb::after {
|
|
227
|
-
content: ' ⤳';
|
|
228
|
-
margin-left: 4px;
|
|
229
|
-
color: var(--fg-50);
|
|
230
|
-
font-weight: 400;
|
|
231
|
-
}
|
|
232
|
-
.steps-mini .step .target {
|
|
233
|
-
color: var(--fg-50);
|
|
234
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
235
|
-
max-width: 280px;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/* Pattern 9 — recovery affordance panel. Renders below the status pill
|
|
239
|
-
when a session ends in a recoverable failure state. Cause-specific
|
|
240
|
-
border color (yellow for failover-eligible, red for needs-human-action).
|
|
241
|
-
Per docs/design/AGENTIC_UX.md Pattern 9. */
|
|
242
|
-
.recovery-affordance {
|
|
243
|
-
margin-top: 10px;
|
|
244
|
-
padding: 10px 12px;
|
|
245
|
-
border: 1px dashed var(--warn);
|
|
246
|
-
border-radius: 8px;
|
|
247
|
-
background: rgba(254,188,46,0.04);
|
|
248
|
-
font-family: var(--sans); font-size: 12px;
|
|
249
|
-
}
|
|
250
|
-
.recovery-affordance[data-cause="auth_invalid"],
|
|
251
|
-
.recovery-affordance[data-cause="auth_missing"],
|
|
252
|
-
.recovery-affordance[data-cause="crashed"] { border-color: var(--block); background: rgba(255,95,87,0.04); }
|
|
253
|
-
.recovery-affordance .ra-msg { color: var(--fg); margin: 0 0 8px; line-height: 1.45; }
|
|
254
|
-
.recovery-affordance .ra-msg b { color: var(--fg); font-weight: 600; }
|
|
255
|
-
.recovery-affordance .ra-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
256
|
-
.recovery-affordance button.ra-primary {
|
|
257
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
258
|
-
padding: 5px 12px;
|
|
259
|
-
border: 1px solid var(--accent);
|
|
260
|
-
background: var(--accent-wash);
|
|
261
|
-
border-radius: 6px;
|
|
262
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg);
|
|
263
|
-
cursor: pointer;
|
|
264
|
-
}
|
|
265
|
-
.recovery-affordance button.ra-primary:hover { background: rgba(255,154,118,0.16); }
|
|
266
|
-
.recovery-affordance button.ra-primary[disabled] { opacity: 0.55; cursor: progress; }
|
|
267
|
-
.recovery-affordance button.ra-secondary {
|
|
268
|
-
background: transparent; border: none;
|
|
269
|
-
color: var(--fg-50); font-family: var(--mono); font-size: 11px;
|
|
270
|
-
cursor: pointer; padding: 5px 8px;
|
|
271
|
-
}
|
|
272
|
-
.recovery-affordance button.ra-secondary:hover { color: var(--fg); }
|
|
273
|
-
|
|
274
|
-
/* Pattern 1 — tool-progress live chip. At-a-glance count + dot-pattern
|
|
275
|
-
progress + elapsed time. Lives above the micro-activity verb so the
|
|
276
|
-
eye reads: chip (numbers) → verb (in-progress action) → step trace. */
|
|
277
|
-
.tool-progress-chip {
|
|
278
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
279
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
280
|
-
padding: 3px 9px;
|
|
281
|
-
background: rgba(255,255,255,0.03);
|
|
282
|
-
border: 1px solid var(--border-soft);
|
|
283
|
-
border-radius: 999px;
|
|
284
|
-
margin-top: 4px;
|
|
285
|
-
transition: opacity 180ms ease, border-color 180ms ease;
|
|
286
|
-
}
|
|
287
|
-
.tool-progress-chip[data-state="hidden"] { display: none; }
|
|
288
|
-
.tool-progress-chip[data-state="running"] {
|
|
289
|
-
border-color: var(--accent);
|
|
290
|
-
animation: tp-pulse 2.4s ease-in-out infinite;
|
|
291
|
-
}
|
|
292
|
-
.tool-progress-chip[data-state="paused"] { border-color: var(--warn); }
|
|
293
|
-
.tool-progress-chip[data-state="complete"] { border-color: var(--allow); }
|
|
294
|
-
.tool-progress-chip[data-state="failed"] { border-color: var(--block); color: var(--block); }
|
|
295
|
-
.tool-progress-chip .tp-sep { color: var(--fg-20); user-select: none; }
|
|
296
|
-
.tool-progress-chip .tp-dots { letter-spacing: 0.06em; color: var(--accent); }
|
|
297
|
-
.tool-progress-chip[data-state="failed"] .tp-dots { color: var(--block); }
|
|
298
|
-
.tool-progress-chip[data-state="complete"] .tp-dots { color: var(--allow); }
|
|
299
|
-
.tool-progress-chip .tp-elapsed { color: var(--fg-40); }
|
|
300
|
-
@keyframes tp-pulse {
|
|
301
|
-
0%, 100% { border-color: var(--border-soft); }
|
|
302
|
-
50% { border-color: var(--accent); }
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/* Running-state pill animation */
|
|
306
|
-
.badge-running-glow { animation: badge-glow 2.4s ease-in-out infinite; }
|
|
307
|
-
|
|
308
|
-
/* Entrance anim */
|
|
309
|
-
.fade-up { animation: fade-up 0.5s cubic-bezier(0.16,1,0.3,1) both; }
|
|
310
|
-
|
|
311
|
-
/* Pulsing dot — for "live" indicators on collapsed running sessions */
|
|
312
|
-
@keyframes dot-pulse {
|
|
313
|
-
0%, 100% { opacity: 0.45; transform: scale(1); }
|
|
314
|
-
50% { opacity: 1; transform: scale(1.18); }
|
|
315
|
-
}
|
|
316
|
-
.live-dot {
|
|
317
|
-
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
|
318
|
-
background: var(--allow); box-shadow: 0 0 6px rgba(40,200,64,0.6);
|
|
319
|
-
animation: dot-pulse 1.4s ease-in-out infinite;
|
|
320
|
-
margin-right: 7px; vertical-align: middle;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/* ── Audit summary row (Gate B3 — moat visible on main page) ────────
|
|
324
|
-
Principle #3 status-at-a-glance + #7 agent-provenance + #6 marketing voice.
|
|
325
|
-
Dot-separated pills matching codehere.uk tone: "Today · 47 actions · 3 blocked · avg trust 91". */
|
|
326
|
-
.audit-summary {
|
|
327
|
-
display: flex; align-items: center; gap: 10px;
|
|
328
|
-
padding: 10px 14px;
|
|
329
|
-
margin: 0 0 10px;
|
|
330
|
-
border: 1px solid var(--border-soft);
|
|
331
|
-
background: rgba(255,154,118,0.03);
|
|
332
|
-
border-radius: 8px;
|
|
333
|
-
font-family: var(--mono); font-size: 11px;
|
|
334
|
-
color: var(--fg-70);
|
|
335
|
-
flex-wrap: wrap;
|
|
336
|
-
}
|
|
337
|
-
.audit-summary .eyebrow {
|
|
338
|
-
color: var(--accent); font-weight: 600; letter-spacing: 0.04em;
|
|
339
|
-
}
|
|
340
|
-
.audit-summary b { color: var(--fg); font-weight: 600; }
|
|
341
|
-
.audit-summary .blocked b { color: var(--block); }
|
|
342
|
-
.audit-summary .sep { color: var(--fg-20); user-select: none; }
|
|
343
|
-
.audit-summary.empty { display: none; }
|
|
344
|
-
|
|
345
|
-
/* ── Hook safety pill on running cards (Gate B1 — safety moat visible live) ──
|
|
346
|
-
Principle #7 agent-provenance. Surfaces the safety layer continuously, not only on block. */
|
|
347
|
-
.safety-pill {
|
|
348
|
-
display: inline-flex; align-items: center; gap: 4px;
|
|
349
|
-
margin-left: 10px;
|
|
350
|
-
padding: 2px 9px;
|
|
351
|
-
border-radius: 999px;
|
|
352
|
-
font-family: var(--mono); font-size: 10px;
|
|
353
|
-
color: var(--fg-50);
|
|
354
|
-
border: 1px solid var(--border-soft);
|
|
355
|
-
background: rgba(255,255,255,0.02);
|
|
356
|
-
}
|
|
357
|
-
.safety-pill .shield { color: var(--allow); }
|
|
358
|
-
.safety-pill .safe { color: var(--fg); font-weight: 600; }
|
|
359
|
-
.safety-pill .blocked { color: var(--block); font-weight: 600; }
|
|
360
|
-
.safety-pill.has-blocked { border-color: rgba(255,95,87,0.25); background: rgba(255,95,87,0.04); }
|
|
361
|
-
|
|
362
|
-
/* ── Status-at-a-glance banner (PM UX Layer 7) ──────────────────────
|
|
363
|
-
Principles #3 "status at a glance", #4 "multi-session default".
|
|
364
|
-
One row answers: is anything running/detected/blocked, when did code last change. */
|
|
365
|
-
.status-banner {
|
|
366
|
-
display: flex; align-items: center; gap: 16px;
|
|
367
|
-
padding: 10px 14px;
|
|
368
|
-
margin: 0 0 18px;
|
|
369
|
-
border: 1px solid var(--border);
|
|
370
|
-
background: rgba(255,255,255,0.02);
|
|
371
|
-
border-radius: 8px;
|
|
372
|
-
font-family: var(--mono); font-size: 11px;
|
|
373
|
-
color: var(--fg-50);
|
|
374
|
-
flex-wrap: wrap;
|
|
375
|
-
}
|
|
376
|
-
.status-banner .stat { display: inline-flex; align-items: center; gap: 6px; }
|
|
377
|
-
.status-banner .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
|
378
|
-
.status-banner .stat .value { color: var(--fg); font-weight: 600; }
|
|
379
|
-
/* Pattern 8 ride-along revert: daemon-live is actionable signal ("daemon
|
|
380
|
-
is alive RIGHT NOW") not passive status. f830183 demoted this to fg
|
|
381
|
-
neutral; restoring to green so the heartbeat reads at glance. */
|
|
382
|
-
.status-banner .stat .value.live { color: var(--allow); }
|
|
383
|
-
.status-banner .stat.blocked .value.nonzero { color: var(--block); }
|
|
384
|
-
.status-banner .sep { color: var(--fg-20); user-select: none; }
|
|
385
|
-
.status-banner .edge {
|
|
386
|
-
margin-left: auto;
|
|
387
|
-
color: var(--fg-50);
|
|
388
|
-
}
|
|
389
|
-
.status-banner .edge .value { color: var(--accent); font-weight: 400; }
|
|
390
|
-
|
|
391
|
-
/* ── Steps strip in collapsed card (PM UX Layer 2) ──────────────────
|
|
392
|
-
Principles #1 "web is not a terminal", #2 "info density".
|
|
393
|
-
Compact list of recent semantic steps instead of raw event chatter. */
|
|
394
|
-
.steps-mini {
|
|
395
|
-
margin: 6px 0 0;
|
|
396
|
-
display: flex; flex-direction: column; gap: 2px;
|
|
397
|
-
font-family: var(--mono); font-size: 11px;
|
|
398
|
-
}
|
|
399
|
-
.steps-mini .step {
|
|
400
|
-
display: flex; align-items: center; gap: 8px;
|
|
401
|
-
color: var(--fg-70);
|
|
402
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
403
|
-
}
|
|
404
|
-
.steps-mini .step .ico {
|
|
405
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
406
|
-
width: 13px; height: 13px; flex-shrink: 0;
|
|
407
|
-
font-size: 10px; font-weight: 700;
|
|
408
|
-
}
|
|
409
|
-
.steps-mini .step.done .ico { color: var(--allow); }
|
|
410
|
-
.steps-mini .step.active .ico { color: var(--accent); animation: dot-pulse 1.4s ease-in-out infinite; }
|
|
411
|
-
.steps-mini .step.failed .ico { color: var(--block); }
|
|
412
|
-
.steps-mini .step.pending .ico { color: var(--fg-40); }
|
|
413
|
-
.steps-mini .more {
|
|
414
|
-
color: var(--fg-40); font-size: 10px; letter-spacing: 0.06em;
|
|
415
|
-
padding-left: 21px;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/* ── Final report block (PM UX Layer 3) ─────────────────────────────
|
|
419
|
-
Principles #2 "info density", #7 "agent-provenance".
|
|
420
|
-
Shows up inside an expanded card when status='completed'. */
|
|
421
|
-
.final-report {
|
|
422
|
-
margin: 10px 0 0;
|
|
423
|
-
padding: 12px 14px;
|
|
424
|
-
border: 1px solid var(--border);
|
|
425
|
-
background: rgba(255,154,118,0.04);
|
|
426
|
-
border-radius: 8px;
|
|
427
|
-
font-family: var(--mono); font-size: 11px;
|
|
428
|
-
}
|
|
429
|
-
.final-report .fr-label {
|
|
430
|
-
color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.12em; font-size: 10px;
|
|
431
|
-
margin-bottom: 8px;
|
|
432
|
-
}
|
|
433
|
-
/* Iter 12 — prose summary block. Plain English for non-technical users.
|
|
434
|
-
Sans-serif breaks the monospace context so it reads as narrative, not data. */
|
|
435
|
-
.final-report .fr-summary {
|
|
436
|
-
font-family: var(--sans);
|
|
437
|
-
font-size: 13px;
|
|
438
|
-
line-height: 1.55;
|
|
439
|
-
color: var(--fg);
|
|
440
|
-
margin-bottom: 12px;
|
|
441
|
-
padding-bottom: 12px;
|
|
442
|
-
border-bottom: 1px solid var(--border-soft);
|
|
443
|
-
}
|
|
444
|
-
.final-report .fr-summary p {
|
|
445
|
-
margin: 0 0 10px 0;
|
|
446
|
-
}
|
|
447
|
-
.final-report .fr-summary p:last-child { margin-bottom: 0; }
|
|
448
|
-
.final-report .fr-summary-pending p {
|
|
449
|
-
color: var(--fg-50);
|
|
450
|
-
font-style: italic;
|
|
451
|
-
animation: shimmer 1.6s linear infinite;
|
|
452
|
-
background: linear-gradient(90deg, var(--fg-30) 0%, var(--fg-70) 50%, var(--fg-30) 100%);
|
|
453
|
-
background-size: 200% auto;
|
|
454
|
-
-webkit-background-clip: text;
|
|
455
|
-
background-clip: text;
|
|
456
|
-
color: transparent;
|
|
457
|
-
}
|
|
458
|
-
.final-report .fr-files { display: flex; flex-direction: column; gap: 3px; }
|
|
459
|
-
.final-report .fr-file {
|
|
460
|
-
display: grid;
|
|
461
|
-
grid-template-columns: 1fr auto auto;
|
|
462
|
-
gap: 12px;
|
|
463
|
-
color: var(--fg-70);
|
|
464
|
-
}
|
|
465
|
-
.final-report .fr-file .file-icon {
|
|
466
|
-
display: inline-block; flex: 0 0 12px;
|
|
467
|
-
width: 12px; height: 12px; opacity: 0.55;
|
|
468
|
-
margin-right: 6px;
|
|
469
|
-
}
|
|
470
|
-
.final-report .fr-file[data-mutation="write"] .file-icon { color: var(--allow); opacity: 1; }
|
|
471
|
-
.final-report .fr-file[data-mutation="edit"] .file-icon { color: var(--allow); opacity: 1; }
|
|
472
|
-
.final-report .fr-file[data-mutation="delete"] .file-icon { color: var(--block); opacity: 1; }
|
|
473
|
-
.final-report .fr-file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
474
|
-
.final-report .fr-file .lines { color: var(--fg-50); white-space: nowrap; }
|
|
475
|
-
.final-report .fr-file .lines .add { color: var(--allow); }
|
|
476
|
-
.final-report .fr-file .lines .rem { color: var(--block); }
|
|
477
|
-
/* Pattern 11 · Trust score badge — standalone, reusable. Compact pill
|
|
478
|
-
"<score> · <grade>" with semantic background. Drop-in replacement
|
|
479
|
-
for the inline trust rendering previously embedded in the file
|
|
480
|
-
chip (P7); now any surface that has a trust score can render the
|
|
481
|
-
same pill via trustBadgeHTML(score). Per AGENTIC_UX.md Pattern 11.
|
|
482
|
-
Color split: A/B (≥75) = ok, C/D (40-74) = warn, F (<40) = block.
|
|
483
|
-
This corrects the prior P7 inline rendering which used a 60-cutoff
|
|
484
|
-
for warn (putting D-grade in red instead of yellow per spec). */
|
|
485
|
-
.trust-badge {
|
|
486
|
-
display: inline-block;
|
|
487
|
-
padding: 1px 7px; border-radius: 999px;
|
|
488
|
-
font-size: 10px; font-weight: 600;
|
|
489
|
-
border: 1px solid transparent;
|
|
490
|
-
min-width: 28px; text-align: center;
|
|
491
|
-
font-family: var(--mono);
|
|
492
|
-
}
|
|
493
|
-
.trust-badge.good { color: var(--allow); background: rgba(40,200,64,0.08); border-color: rgba(40,200,64,0.22); }
|
|
494
|
-
.trust-badge.warn { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
|
|
495
|
-
.trust-badge.bad { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
|
|
496
|
-
.trust-badge.none { color: var(--fg-40); background: transparent; border-color: var(--border-soft); }
|
|
497
|
-
.final-report .fr-tests, .final-report .fr-none {
|
|
498
|
-
margin-top: 8px; padding-top: 8px;
|
|
499
|
-
border-top: 1px solid var(--border-soft);
|
|
500
|
-
color: var(--fg-50);
|
|
501
|
-
}
|
|
502
|
-
.final-report .fr-tests .pass { color: var(--allow); }
|
|
503
|
-
.final-report .fr-tests .fail { color: var(--block); }
|
|
504
|
-
|
|
505
|
-
/* Floating pill nav — landing's FloatingNav visual */
|
|
506
|
-
.floating-nav {
|
|
507
|
-
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
|
|
508
|
-
z-index: 50;
|
|
509
|
-
display: flex; align-items: center; gap: 24px;
|
|
510
|
-
padding: 10px 20px;
|
|
511
|
-
border-radius: 999px;
|
|
512
|
-
border: 1px solid rgba(255,255,255,0.10);
|
|
513
|
-
background: rgba(255,255,255,0.03);
|
|
514
|
-
backdrop-filter: blur(20px);
|
|
515
|
-
-webkit-backdrop-filter: blur(20px);
|
|
516
|
-
transition: background 0.3s ease, box-shadow 0.3s ease;
|
|
517
|
-
}
|
|
518
|
-
.floating-nav:hover { background: rgba(0,0,0,0.6); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
|
|
519
|
-
.floating-nav .logo {
|
|
520
|
-
font-family: var(--sans); font-weight: 800; font-size: 14px;
|
|
521
|
-
background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
|
|
522
|
-
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
|
523
|
-
}
|
|
524
|
-
.floating-nav a.nav-link {
|
|
525
|
-
font-family: var(--mono); font-size: 12px; color: var(--fg-50);
|
|
526
|
-
text-decoration: none; transition: color 0.2s ease;
|
|
527
|
-
}
|
|
528
|
-
.floating-nav a.nav-link:hover { color: var(--fg); }
|
|
529
|
-
.floating-nav a.nav-link.active { color: var(--fg); }
|
|
530
|
-
/* Wiring to codehere.uk — external link styled as the same nav-link
|
|
531
|
-
family but with an opacity tell so the user knows it leaves the app. */
|
|
532
|
-
.floating-nav a.nav-external { opacity: 0.65; }
|
|
533
|
-
.floating-nav a.nav-external:hover { opacity: 1; color: var(--accent); }
|
|
534
|
-
|
|
535
|
-
/* Pattern 20 — hook-mode indicator pill in the floating nav. Surfaces
|
|
536
|
-
codehere's safety posture (block / advisory / log-only / off) so the
|
|
537
|
-
user can tell at a glance what the hook is currently doing. Read-only
|
|
538
|
-
for now — clicking opens nothing. Mode switching ships separately
|
|
539
|
-
through codehere hook install/uninstall so the safety surface
|
|
540
|
-
re-validates instead of mutating from the UI. Per AGENTIC_UX.md
|
|
541
|
-
Pattern 20. */
|
|
542
|
-
.mode-indicator {
|
|
543
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
544
|
-
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
|
545
|
-
padding: 3px 10px; border-radius: 999px;
|
|
546
|
-
border: 1px solid var(--border);
|
|
547
|
-
background: rgba(255,255,255,0.03);
|
|
548
|
-
color: var(--fg-50);
|
|
549
|
-
cursor: help;
|
|
550
|
-
user-select: none;
|
|
551
|
-
}
|
|
552
|
-
.mode-indicator .mi-dot {
|
|
553
|
-
width: 7px; height: 7px; border-radius: 50%;
|
|
554
|
-
background: currentColor;
|
|
555
|
-
flex-shrink: 0;
|
|
556
|
-
}
|
|
557
|
-
.mode-indicator[data-mode="block"] { color: var(--block); border-color: rgba(255,95,87,0.35); background: rgba(255,95,87,0.06); }
|
|
558
|
-
.mode-indicator[data-mode="advisory"] { color: var(--warn); border-color: rgba(254,188,46,0.35); background: rgba(254,188,46,0.06); }
|
|
559
|
-
.mode-indicator[data-mode="log-only"] { color: var(--fg-50); border-color: var(--border); }
|
|
560
|
-
.mode-indicator[data-mode="off"] { color: var(--fg-30); border-style: dashed; }
|
|
561
|
-
/* Loading state — neutral pill until first /api/hook/mode resolves. */
|
|
562
|
-
.mode-indicator[data-mode=""] { opacity: 0.4; }
|
|
563
|
-
`;
|
|
564
|
-
// Shared client-JS for the per-agent identity badge. Interpolated into
|
|
565
|
-
// every page's IIFE (TASK_HTML, THREAD_HTML, HISTORY_HTML if it gains
|
|
566
|
-
// agent rendering). Single source of truth so brand SVGs stay consistent
|
|
567
|
-
// across pages — no copy-paste drift between dashboard / threads / history.
|
|
568
|
-
const AGENT_BADGE_JS = `
|
|
569
|
-
// Per-agent label shown next to badge / in tooltips / in dropdowns.
|
|
570
|
-
function agentLabel(id) {
|
|
571
|
-
var labels = { 'claude-code': 'Claude Code', 'codex': 'Codex', 'aider': 'Aider', 'gemini': 'Gemini', 'opencode': 'OpenCode', 'conductor': 'codehere · Conductor', 'kiro': 'Kiro' };
|
|
572
|
-
return labels[id] || id || 'agent';
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Distinctive geometric marks per adapter — codehere-authored (not vendor
|
|
576
|
-
// brand reproductions). Each shape evokes its agent's domain so users
|
|
577
|
-
// glance-identify which agent ran a task without me reproducing brand SVG
|
|
578
|
-
// path data verbatim. currentColor lets the badge's contrast handling
|
|
579
|
-
// (light text on dark slots, dark text on slots 2 + 5) carry through.
|
|
580
|
-
var AGENT_ICONS = {
|
|
581
|
-
// Claude — 8-spoke burst (spark / generation)
|
|
582
|
-
'claude-code':
|
|
583
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true"><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none">' +
|
|
584
|
-
'<line x1="12" y1="4" x2="12" y2="20"/><line x1="4" y1="12" x2="20" y2="12"/>' +
|
|
585
|
-
'<line x1="6.3" y1="6.3" x2="17.7" y2="17.7"/><line x1="17.7" y1="6.3" x2="6.3" y2="17.7"/></g></svg>',
|
|
586
|
-
// Codex — concentric ring (orb)
|
|
587
|
-
'codex':
|
|
588
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
|
|
589
|
-
'<circle cx="12" cy="12" r="7" stroke="currentColor" stroke-width="1.8" fill="none"/>' +
|
|
590
|
-
'<circle cx="12" cy="12" r="2.2" fill="currentColor"/></svg>',
|
|
591
|
-
// Aider — equilateral triangle (forward / pair)
|
|
592
|
-
'aider':
|
|
593
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
|
|
594
|
-
'<path d="M12 4 L20.5 19 L3.5 19 Z"/></svg>',
|
|
595
|
-
// Gemini — 4-point star / diamond
|
|
596
|
-
'gemini':
|
|
597
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
|
|
598
|
-
'<path d="M12 2 L14.2 9.8 L22 12 L14.2 14.2 L12 22 L9.8 14.2 L2 12 L9.8 9.8 Z"/></svg>',
|
|
599
|
-
// OpenCode — angle brackets
|
|
600
|
-
'opencode':
|
|
601
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
602
|
-
'<path d="M9 6 L3 12 L9 18 M15 6 L21 12 L15 18"/></svg>',
|
|
603
|
-
// Conductor — codehere router; lightning shape on brand peach
|
|
604
|
-
'conductor':
|
|
605
|
-
'<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
|
|
606
|
-
'<path d="M13 2 L4 14 L11 14 L9 22 L18 10 L11 10 L13 2 Z"/></svg>'
|
|
607
|
-
};
|
|
608
|
-
var UNKNOWN_AGENT_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="6"/></svg>';
|
|
609
|
-
|
|
610
|
-
var AGENT_SLOTS = {
|
|
611
|
-
'conductor': '0',
|
|
612
|
-
'claude-code': '1',
|
|
613
|
-
'codex': '2',
|
|
614
|
-
'aider': '3',
|
|
615
|
-
'gemini': '4',
|
|
616
|
-
'opencode': '5'
|
|
617
|
-
};
|
|
618
|
-
function agentSlot(id) { return AGENT_SLOTS[id] || 'unknown'; }
|
|
619
|
-
function agentIconSVG(id) { return AGENT_ICONS[id] || UNKNOWN_AGENT_ICON; }
|
|
620
|
-
function escapeAgentTitle(s) {
|
|
621
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
|
622
|
-
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
function agentBadgeHTML(id) {
|
|
626
|
-
return '<span class="agent-badge" data-slot="' + agentSlot(id) + '" title="' + escapeAgentTitle(agentLabel(id)) + '">' + agentIconSVG(id) + '</span>';
|
|
627
|
-
}
|
|
628
|
-
function agentPresenceDotHTML(id) {
|
|
629
|
-
return '<span class="agent-presence-dot" data-slot="' + agentSlot(id) + '" title="' + escapeAgentTitle(agentLabel(id)) + '"></span>';
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Pattern 13 — micro-activity verb rotation. Codehere-authored vocabulary
|
|
633
|
-
// describing the WORK ("Reading the code…") not the WORKER ("I'm thinking").
|
|
634
|
-
// Same pool fires for every adapter — cross-vendor coherence per
|
|
635
|
-
// claim_market_position v4. Ban list (cute mascot, magic/cooking
|
|
636
|
-
// metaphors, anatomy theatrics, overly technical, first-person, speed
|
|
637
|
-
// promises) lives in docs/design/AGENTIC_UX.md Pattern 13.
|
|
638
|
-
var MICRO_VERBS = [
|
|
639
|
-
'Working through it…', 'Pulling the thread…', 'Connecting the dots…',
|
|
640
|
-
'Taking notes…', 'Sorting it out…', 'Mapping things…',
|
|
641
|
-
'Tracking it down…', 'Putting it together…', 'Checking the work…',
|
|
642
|
-
'Lining things up…', 'Reading the code…', 'Tracing the path…',
|
|
643
|
-
'Piecing it together…', 'Walking the repo…'
|
|
644
|
-
];
|
|
645
|
-
|
|
646
|
-
// Pick a phrase NOT recently shown so the user doesn't see the same
|
|
647
|
-
// word twice within a 30s window (pool of 14, max 6 swaps per 30s at
|
|
648
|
-
// 5s cadence; keeping last 5 in a queue is sufficient).
|
|
649
|
-
function pickWhimsyVerb(recentQueue) {
|
|
650
|
-
var pool = [];
|
|
651
|
-
for (var i = 0; i < MICRO_VERBS.length; i++) {
|
|
652
|
-
if (recentQueue.indexOf(MICRO_VERBS[i]) === -1) pool.push(MICRO_VERBS[i]);
|
|
653
|
-
}
|
|
654
|
-
if (pool.length === 0) pool = MICRO_VERBS;
|
|
655
|
-
return pool[Math.floor(Math.random() * pool.length)];
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Start a whimsy rotator on a target element. Returns a stop() function
|
|
659
|
-
// the caller invokes when the session exits or transitions to a tool-
|
|
660
|
-
// prefixed state. Cadence: 4-6s random; ensures the verb feels alive
|
|
661
|
-
// without strobing.
|
|
662
|
-
// Pattern 2 — derive a tool-kind tag from a step label so the renderer
|
|
663
|
-
// can apply the verb-color semantic. Adapters emit free-form labels
|
|
664
|
-
// ("Reading lib/foo.ts" / "Running bun test" / "grep auth patterns"),
|
|
665
|
-
// so we match the leading word against known tool families.
|
|
666
|
-
function inferToolKind(label) {
|
|
667
|
-
var s = String(label || '').trim().toLowerCase();
|
|
668
|
-
if (!s) return 'unknown';
|
|
669
|
-
var first = s.split(' ')[0].replace(/[:.,]+$/, '');
|
|
670
|
-
if (first === 'read' || first === 'reading' || first === 'cat') return 'read';
|
|
671
|
-
if (first === 'write' || first === 'writing' || first === 'create') return 'write';
|
|
672
|
-
if (first === 'edit' || first === 'editing' || first === 'multiedit' || first === 'patch') return 'edit';
|
|
673
|
-
if (first === 'bash' || first === 'run' || first === 'running' || first === 'shell' || first === 'exec') return 'bash';
|
|
674
|
-
if (first === 'grep' || first === 'searching' || first === 'search') return 'grep';
|
|
675
|
-
if (first === 'glob' || first === 'find') return 'glob';
|
|
676
|
-
if (first === 'ls' || first === 'list' || first === 'listing') return 'list';
|
|
677
|
-
if (first === 'fetch' || first === 'fetching' || first === 'webfetch' || first === 'curl' || first === 'wget') return 'fetch';
|
|
678
|
-
if (first === 'websearch') return 'search';
|
|
679
|
-
if (first === 'task' || first === 'dispatch' || first === 'dispatching' || first === 'subagent') return 'task';
|
|
680
|
-
return 'unknown';
|
|
681
|
-
}
|
|
682
|
-
// Split a step label into (verb, target) for verb-color rendering. The
|
|
683
|
-
// verb is the first word; everything after is the target. Falls back to
|
|
684
|
-
// (label, '') when the label is a single word.
|
|
685
|
-
function splitStepLabel(label) {
|
|
686
|
-
var s = String(label || '').trim();
|
|
687
|
-
if (!s) return { verb: '', target: '' };
|
|
688
|
-
var space = s.indexOf(' ');
|
|
689
|
-
if (space === -1) return { verb: s, target: '' };
|
|
690
|
-
return { verb: s.slice(0, space), target: s.slice(space + 1) };
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Pattern 14 — per-tool target truncation. Smart truncation beats CSS
|
|
694
|
-
// ellipsis because it preserves the meaningful chunk per tool kind: a
|
|
695
|
-
// file path's last 2 segments stay readable, a bash command keeps its
|
|
696
|
-
// binary + last arg, a URL keeps its domain.
|
|
697
|
-
function truncateTarget(toolKind, target) {
|
|
698
|
-
var s = String(target == null ? '' : target).trim();
|
|
699
|
-
if (!s) return '';
|
|
700
|
-
|
|
701
|
-
if (toolKind === 'read' || toolKind === 'write' || toolKind === 'edit' || toolKind === 'list') {
|
|
702
|
-
// File path: show last 2 segments, prefix with "…/" when shortened.
|
|
703
|
-
var parts = s.split('/').filter(function(p) { return p.length > 0; });
|
|
704
|
-
if (parts.length <= 2) return s;
|
|
705
|
-
return '…/' + parts.slice(-2).join('/');
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (toolKind === 'bash') {
|
|
709
|
-
// Bash command: keep binary + final arg if total > 40 chars.
|
|
710
|
-
if (s.length <= 40) return s;
|
|
711
|
-
var spaceIdx = s.indexOf(' ');
|
|
712
|
-
if (spaceIdx === -1) return s.slice(0, 37) + '…';
|
|
713
|
-
var binary = s.slice(0, spaceIdx);
|
|
714
|
-
var lastSpace = s.lastIndexOf(' ');
|
|
715
|
-
var lastArg = s.slice(lastSpace + 1);
|
|
716
|
-
// Compose: "binary … lastArg" within budget. Cap each piece at 18.
|
|
717
|
-
var budget = 40;
|
|
718
|
-
var b = binary.length > 18 ? binary.slice(0, 17) + '…' : binary;
|
|
719
|
-
var l = lastArg.length > 18 ? '…' + lastArg.slice(-17) : lastArg;
|
|
720
|
-
var composed = b + ' … ' + l;
|
|
721
|
-
if (composed.length <= budget) return composed;
|
|
722
|
-
// Last resort: hard mid-truncate
|
|
723
|
-
return s.slice(0, 19) + '…' + s.slice(-19);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (toolKind === 'fetch') {
|
|
727
|
-
// URL: domain + first path segment. Strip protocol; truncate the
|
|
728
|
-
// tail at 30 chars after the domain. (String-strip instead of regex
|
|
729
|
-
// because backslashes get stripped by the outer template literal.)
|
|
730
|
-
var stripped = s;
|
|
731
|
-
if (stripped.indexOf('https://') === 0) stripped = stripped.slice(8);
|
|
732
|
-
else if (stripped.indexOf('http://') === 0) stripped = stripped.slice(7);
|
|
733
|
-
var slash = stripped.indexOf('/');
|
|
734
|
-
if (slash === -1) return stripped;
|
|
735
|
-
var domain = stripped.slice(0, slash);
|
|
736
|
-
var path = stripped.slice(slash);
|
|
737
|
-
if (path.length > 30) path = path.slice(0, 27) + '…';
|
|
738
|
-
return domain + path;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (toolKind === 'grep' || toolKind === 'search' || toolKind === 'glob') {
|
|
742
|
-
// Search pattern: 30 chars verbatim then ellipsis. Patterns are
|
|
743
|
-
// user-meaningful even when truncated mid-token; preserve as-is.
|
|
744
|
-
if (s.length <= 30) return s;
|
|
745
|
-
return s.slice(0, 29) + '…';
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Unknown tool family: hard 40-char cap with ellipsis at the end.
|
|
749
|
-
if (s.length <= 40) return s;
|
|
750
|
-
return s.slice(0, 39) + '…';
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Pattern 20 — hook-mode indicator wiring. Hits /api/hook/mode on
|
|
754
|
-
// load + every 30s; renders a colored pill in the floating nav with
|
|
755
|
-
// mode-specific color, dot, and tooltip. Self-installing — runs once
|
|
756
|
-
// per page if a #mode-indicator element exists. Failures degrade
|
|
757
|
-
// silently (mode pill stays in the loading state).
|
|
758
|
-
var MODE_LABELS = {
|
|
759
|
-
// Plain-English labels (claim_user_persona) BUT honest about scope —
|
|
760
|
-
// codehere is a SECOND screen, not the primary safety system. Prior
|
|
761
|
-
// labels ("Safety on") implied codehere IS the safety system. It's
|
|
762
|
-
// not — the agent vendor (Claude / Codex / etc.) has its own safety
|
|
763
|
-
// stack. Codehere's job is defense-in-depth + the cross-vendor
|
|
764
|
-
// audit trail. Labels now say "Codehere · X" so the scope is clear.
|
|
765
|
-
'block': { label: 'Codehere · blocking', tip: 'Codehere screens every tool call against its pattern bank and blocks risky ones. Runs ON TOP of the agent vendor own safety; second screen, not primary.' },
|
|
766
|
-
'advisory': { label: 'Codehere · warn', tip: 'Codehere warns about risky tool calls but does not block. Defense-in-depth on top of the agent vendor own safety.' },
|
|
767
|
-
'log-only': { label: 'Codehere · recording',tip: 'Codehere records every tool call to the local audit log but does not intervene. Useful for cross-vendor audit trail without blocking.' },
|
|
768
|
-
'off': { label: 'Codehere · off', tip: 'No codehere guardrail. The agent vendor own safety is still active. Run codehere hook install to add codehere extra screen.' },
|
|
769
|
-
};
|
|
770
|
-
function paintModeIndicator(mode) {
|
|
771
|
-
var el = document.getElementById('mode-indicator');
|
|
772
|
-
if (!el) return;
|
|
773
|
-
var spec = MODE_LABELS[mode] || MODE_LABELS['off'];
|
|
774
|
-
el.dataset.mode = mode;
|
|
775
|
-
el.title = spec.tip;
|
|
776
|
-
var labelEl = el.querySelector('.mi-label');
|
|
777
|
-
if (labelEl) labelEl.textContent = spec.label;
|
|
778
|
-
}
|
|
779
|
-
function pollHookMode() {
|
|
780
|
-
fetch('/api/hook/mode')
|
|
781
|
-
.then(function(r) { return r.ok ? r.json() : null; })
|
|
782
|
-
.then(function(data) { if (data && data.mode) paintModeIndicator(data.mode); })
|
|
783
|
-
.catch(function() { /* leave loading state — next poll may succeed */ });
|
|
784
|
-
}
|
|
785
|
-
if (typeof document !== 'undefined' && document.getElementById('mode-indicator')) {
|
|
786
|
-
pollHookMode();
|
|
787
|
-
setInterval(pollHookMode, 30000);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Pattern 11 — trust score badge HTML. Returns a pill of shape
|
|
791
|
-
// "<score> · <grade>" with semantic class. Thresholds match
|
|
792
|
-
// trust-scorer.ts; color split per AGENTIC_UX.md Pattern 11
|
|
793
|
-
// (A/B=ok ≥75, C/D=warn ≥40, F=block <40, n/a=muted).
|
|
794
|
-
// Caller decides container; this just emits the standalone pill.
|
|
795
|
-
function trustBadgeHTML(score) {
|
|
796
|
-
if (typeof score !== 'number' || isNaN(score)) {
|
|
797
|
-
return '<span class="trust-badge none" title="Trust score">—</span>';
|
|
798
|
-
}
|
|
799
|
-
var s = Math.max(0, Math.min(100, Math.round(score)));
|
|
800
|
-
var grade =
|
|
801
|
-
s >= 90 ? 'A' :
|
|
802
|
-
s >= 75 ? 'B' :
|
|
803
|
-
s >= 60 ? 'C' :
|
|
804
|
-
s >= 40 ? 'D' : 'F';
|
|
805
|
-
var cls =
|
|
806
|
-
s >= 75 ? 'good' :
|
|
807
|
-
s >= 40 ? 'warn' : 'bad';
|
|
808
|
-
return '<span class="trust-badge ' + cls + '" title="Trust score · ' + grade + '">' +
|
|
809
|
-
s + ' · ' + grade +
|
|
810
|
-
'</span>';
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Pattern 3 — cwd tail. Returns the basename of an absolute path so the
|
|
814
|
-
// session card byline reads "claude-code · codehere · 09:01" instead of
|
|
815
|
-
// a 60-char absolute path that wraps. Strips a trailing slash, then
|
|
816
|
-
// returns the last non-empty segment. Empty / weird input: empty string.
|
|
817
|
-
function cwdTail(cwd) {
|
|
818
|
-
var s = String(cwd == null ? '' : cwd).trim();
|
|
819
|
-
if (!s) return '';
|
|
820
|
-
while (s.length > 1 && s.charAt(s.length - 1) === '/') s = s.slice(0, -1);
|
|
821
|
-
var slash = s.lastIndexOf('/');
|
|
822
|
-
if (slash === -1) return s;
|
|
823
|
-
return s.slice(slash + 1);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Pattern 3 — file-edit count. Walks steps, collects unique target paths
|
|
827
|
-
// for file-mutating tools (write/edit), returns size of the set. Read-
|
|
828
|
-
// only tools (read/grep/list/glob) intentionally excluded — "files
|
|
829
|
-
// touched" in the footer means files the agent CHANGED, not files it
|
|
830
|
-
// looked at, which matches the user's mental model.
|
|
831
|
-
function countTouchedFiles(steps) {
|
|
832
|
-
if (!Array.isArray(steps) || steps.length === 0) return 0;
|
|
833
|
-
var seen = Object.create(null);
|
|
834
|
-
var n = 0;
|
|
835
|
-
for (var i = 0; i < steps.length; i++) {
|
|
836
|
-
var st = steps[i];
|
|
837
|
-
if (!st) continue;
|
|
838
|
-
var kind = inferToolKind(st.tool || '');
|
|
839
|
-
if (kind !== 'write' && kind !== 'edit') continue;
|
|
840
|
-
var parts = splitStepLabel(String(st.label || ''));
|
|
841
|
-
var t = parts.target;
|
|
842
|
-
if (!t || seen[t]) continue;
|
|
843
|
-
seen[t] = true;
|
|
844
|
-
n++;
|
|
845
|
-
}
|
|
846
|
-
return n;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// Pattern 9 — failover chain mirrors failover-policy.ts DEFAULT_FAILOVER_CHAIN.
|
|
850
|
-
// Client-side lookup so the recovery affordance can render without a server
|
|
851
|
-
// round-trip; the actual spawn still hits /api/task on click.
|
|
852
|
-
var FAILOVER_CHAIN = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
|
|
853
|
-
function pickFailoverTarget(currentAgent) {
|
|
854
|
-
var idx = FAILOVER_CHAIN.indexOf(currentAgent);
|
|
855
|
-
if (idx === -1) return FAILOVER_CHAIN[0]; // unknown current → start of chain
|
|
856
|
-
return FAILOVER_CHAIN[(idx + 1) % FAILOVER_CHAIN.length];
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// Pattern 9 — cause-specific copy + action labels per failure code. Auth
|
|
860
|
-
// codes get a "Sign in" CTA (not failover-eligible per failover-policy);
|
|
861
|
-
// crashed/refused get the cross-vendor retry button. Falls back to a
|
|
862
|
-
// generic retry message for unknown codes.
|
|
863
|
-
function recoveryCopy(code, currentAgent, nextAgent) {
|
|
864
|
-
var nextLabel = agentLabel(nextAgent);
|
|
865
|
-
var curLabel = agentLabel(currentAgent);
|
|
866
|
-
if (code === 'rate_limit') {
|
|
867
|
-
return {
|
|
868
|
-
msg: '<b>' + escapeAgentTitle(curLabel) + '</b> is rate-limited. <b>' + escapeAgentTitle(nextLabel) + '</b> is available with the same prompt.',
|
|
869
|
-
primary: 'Retry with ' + nextLabel,
|
|
870
|
-
secondary: 'Wait it out',
|
|
871
|
-
canFailover: true
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
if (code === 'quota_exceeded') {
|
|
875
|
-
return {
|
|
876
|
-
msg: 'You hit <b>' + escapeAgentTitle(curLabel) + '</b>’s quota. <b>' + escapeAgentTitle(nextLabel) + '</b> can take this prompt.',
|
|
877
|
-
primary: 'Switch to ' + nextLabel,
|
|
878
|
-
secondary: 'Manage billing',
|
|
879
|
-
canFailover: true
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
if (code === 'credit_low') {
|
|
883
|
-
return {
|
|
884
|
-
msg: '<b>' + escapeAgentTitle(curLabel) + '</b> credits are running low. Top up or switch agents.',
|
|
885
|
-
primary: 'Switch to ' + nextLabel,
|
|
886
|
-
secondary: 'Top up ' + curLabel,
|
|
887
|
-
canFailover: true
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
if (code === 'auth_invalid' || code === 'auth_missing') {
|
|
891
|
-
return {
|
|
892
|
-
msg: '<b>' + escapeAgentTitle(curLabel) + '</b> needs sign-in. Switching agents won’t fix the underlying auth issue.',
|
|
893
|
-
primary: 'Open setup for ' + curLabel,
|
|
894
|
-
secondary: 'Try ' + nextLabel + ' instead',
|
|
895
|
-
canFailover: false // auth fix takes precedence over failover
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
if (code === 'crashed') {
|
|
899
|
-
return {
|
|
900
|
-
msg: '<b>' + escapeAgentTitle(curLabel) + '</b> crashed. <b>' + escapeAgentTitle(nextLabel) + '</b> can pick up where it left off.',
|
|
901
|
-
primary: 'Retry with ' + nextLabel,
|
|
902
|
-
secondary: 'View error',
|
|
903
|
-
canFailover: true
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
return {
|
|
907
|
-
msg: 'Something went wrong. <b>' + escapeAgentTitle(nextLabel) + '</b> can take this prompt.',
|
|
908
|
-
primary: 'Try ' + nextLabel,
|
|
909
|
-
secondary: 'Dismiss',
|
|
910
|
-
canFailover: true
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Build the recovery-affordance markup. Returns an HTMLElement ready to
|
|
915
|
-
// append to the session card. Caller wires the click handlers.
|
|
916
|
-
function buildRecoveryAffordance(code, currentAgent, nextAgent) {
|
|
917
|
-
var copy = recoveryCopy(code, currentAgent, nextAgent);
|
|
918
|
-
var div = document.createElement('div');
|
|
919
|
-
div.className = 'recovery-affordance';
|
|
920
|
-
div.dataset.cause = String(code || '');
|
|
921
|
-
div.innerHTML =
|
|
922
|
-
'<p class="ra-msg">' + copy.msg + '</p>' +
|
|
923
|
-
'<div class="ra-actions">' +
|
|
924
|
-
'<button class="ra-primary" type="button">' + (copy.canFailover ? agentBadgeHTML(nextAgent) + ' ' : '') + escapeAgentTitle(copy.primary) + '</button>' +
|
|
925
|
-
'<button class="ra-secondary" type="button">' + escapeAgentTitle(copy.secondary) + '</button>' +
|
|
926
|
-
'</div>';
|
|
927
|
-
return { el: div, copy: copy };
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function startWhimsyRotator(el) {
|
|
931
|
-
if (!el) return function() {};
|
|
932
|
-
var recent = [];
|
|
933
|
-
function tick() {
|
|
934
|
-
var verb = pickWhimsyVerb(recent);
|
|
935
|
-
recent.push(verb);
|
|
936
|
-
if (recent.length > 5) recent.shift();
|
|
937
|
-
el.textContent = verb;
|
|
938
|
-
}
|
|
939
|
-
tick();
|
|
940
|
-
var timer;
|
|
941
|
-
function schedule() {
|
|
942
|
-
var delay = 4000 + Math.random() * 2000;
|
|
943
|
-
timer = setTimeout(function() { tick(); schedule(); }, delay);
|
|
944
|
-
}
|
|
945
|
-
schedule();
|
|
946
|
-
return function stop() {
|
|
947
|
-
if (timer) clearTimeout(timer);
|
|
948
|
-
timer = null;
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
`;
|
|
952
|
-
// ── /history — audit feed (what we shipped earlier in this session) ────────────
|
|
953
|
-
export const HISTORY_HTML = `<!doctype html>
|
|
954
|
-
<html lang="en">
|
|
955
|
-
<head>
|
|
956
|
-
<meta charset="utf-8" />
|
|
957
|
-
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
958
|
-
<title>codehere · Mission Control</title>
|
|
959
|
-
<style>
|
|
960
|
-
${SHARED_STYLES}
|
|
961
|
-
* { box-sizing: border-box; }
|
|
962
|
-
html, body { height: 100%; }
|
|
963
|
-
body {
|
|
964
|
-
margin: 0;
|
|
965
|
-
background: var(--bg);
|
|
966
|
-
color: var(--fg);
|
|
967
|
-
font-family: var(--sans);
|
|
968
|
-
font-size: 13px;
|
|
969
|
-
line-height: 1.55;
|
|
970
|
-
-webkit-font-smoothing: antialiased;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/* ── Header ─────────────────────────────────────────────────────────── */
|
|
974
|
-
header {
|
|
975
|
-
position: sticky; top: 0; z-index: 10;
|
|
976
|
-
background: rgba(0,0,0,0.75);
|
|
977
|
-
backdrop-filter: blur(12px);
|
|
978
|
-
-webkit-backdrop-filter: blur(12px);
|
|
979
|
-
border-bottom: 1px solid var(--border);
|
|
980
|
-
padding: 14px 24px;
|
|
981
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
982
|
-
}
|
|
983
|
-
header .brand { display: flex; align-items: center; gap: 14px; }
|
|
984
|
-
header .title {
|
|
985
|
-
font-family: var(--mono);
|
|
986
|
-
font-size: 13px; font-weight: 600;
|
|
987
|
-
letter-spacing: 0.01em;
|
|
988
|
-
}
|
|
989
|
-
header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
|
|
990
|
-
header .meta {
|
|
991
|
-
display: flex; align-items: center; gap: 14px;
|
|
992
|
-
font-family: var(--mono);
|
|
993
|
-
font-size: 11px; color: var(--fg-50);
|
|
994
|
-
}
|
|
995
|
-
header .meta .dot {
|
|
996
|
-
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
|
997
|
-
background: var(--fg-30); margin-right: 6px; vertical-align: middle;
|
|
998
|
-
}
|
|
999
|
-
header .meta .dot.live { background: var(--accent); }
|
|
1000
|
-
header .eyebrow {
|
|
1001
|
-
display: inline-block;
|
|
1002
|
-
font-family: var(--mono);
|
|
1003
|
-
font-size: 10px; letter-spacing: 0.12em;
|
|
1004
|
-
color: var(--fg-40);
|
|
1005
|
-
text-transform: uppercase;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/* ── Tagline under header ───────────────────────────────────────────── */
|
|
1009
|
-
.tagline {
|
|
1010
|
-
padding: 14px 24px 0;
|
|
1011
|
-
font-family: var(--mono); font-size: 12px; color: var(--fg-50);
|
|
1012
|
-
letter-spacing: 0.01em;
|
|
1013
|
-
}
|
|
1014
|
-
.tagline b { color: var(--fg-70); font-weight: 600; }
|
|
1015
|
-
|
|
1016
|
-
/* ── Status strip (answers A/B/C/D in one row) ──────────────────────── */
|
|
1017
|
-
.status {
|
|
1018
|
-
margin: 14px 24px 4px;
|
|
1019
|
-
padding: 12px 16px;
|
|
1020
|
-
border: 1px solid var(--border);
|
|
1021
|
-
background: rgba(255,255,255,0.02);
|
|
1022
|
-
border-radius: 8px;
|
|
1023
|
-
display: flex; gap: 22px; flex-wrap: wrap;
|
|
1024
|
-
font-family: var(--mono); font-size: 12px;
|
|
1025
|
-
}
|
|
1026
|
-
.status .stat { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
1027
|
-
.status .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
|
1028
|
-
.status .stat .value { color: var(--fg); font-weight: 600; }
|
|
1029
|
-
.status .stat.blocked .value.nonzero { color: var(--block); }
|
|
1030
|
-
.status .stat .value.live { color: var(--allow); }
|
|
1031
|
-
.status .stat.latest { flex: 1 1 260px; min-width: 200px; }
|
|
1032
|
-
.status .stat.latest .value { color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; }
|
|
1033
|
-
.status .stat.latest .ago { color: var(--fg-40); font-weight: 400; }
|
|
1034
|
-
.status .sep { color: var(--fg-20); user-select: none; }
|
|
1035
|
-
|
|
1036
|
-
/* ── Section label ─────────────────────────────────────────────────── */
|
|
1037
|
-
.section-label {
|
|
1038
|
-
padding: 18px 24px 6px;
|
|
1039
|
-
font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
|
|
1040
|
-
color: var(--fg-40); text-transform: uppercase;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/* ── Feed ──────────────────────────────────────────────────────────── */
|
|
1044
|
-
main { padding-bottom: 48px; }
|
|
1045
|
-
/* Bug A5 — filter chip bar above the feed. Lets the user narrow the
|
|
1046
|
-
wall-of-text down to outcomes (allow/block/override) or specific
|
|
1047
|
-
tool families (Bash/Edit/Write/Read). Active chip glows with the
|
|
1048
|
-
accent border. */
|
|
1049
|
-
.feed-filters {
|
|
1050
|
-
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
|
|
1051
|
-
margin: 0 0 12px;
|
|
1052
|
-
padding: 0 0 8px;
|
|
1053
|
-
border-bottom: 1px solid var(--border-soft);
|
|
1054
|
-
}
|
|
1055
|
-
.feed-filters .filter-chip {
|
|
1056
|
-
background: transparent;
|
|
1057
|
-
border: 1px solid var(--border);
|
|
1058
|
-
border-radius: 999px;
|
|
1059
|
-
color: var(--fg-50);
|
|
1060
|
-
cursor: pointer;
|
|
1061
|
-
font-family: var(--mono); font-size: 10.5px;
|
|
1062
|
-
padding: 3px 9px;
|
|
1063
|
-
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
|
1064
|
-
}
|
|
1065
|
-
.feed-filters .filter-chip:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1066
|
-
.feed-filters .filter-chip.active {
|
|
1067
|
-
color: var(--accent);
|
|
1068
|
-
border-color: var(--accent);
|
|
1069
|
-
background: var(--accent-wash);
|
|
1070
|
-
}
|
|
1071
|
-
.feed-filters .filter-chip-clear { color: var(--fg-30); }
|
|
1072
|
-
.feed-filters .filter-chip-clear:hover { color: var(--block); border-color: var(--block); }
|
|
1073
|
-
.feed-filters .filter-sep { color: var(--fg-20); user-select: none; padding: 0 2px; }
|
|
1074
|
-
/* Hide rows whose data-outcome or data-tool doesn't match the active chips. */
|
|
1075
|
-
#feed.filtered li:not(.feed-match) { display: none; }
|
|
1076
|
-
|
|
1077
|
-
#feed { list-style: none; margin: 0; padding: 0; }
|
|
1078
|
-
/* Row layout — flex, not grid. Earlier grid had a fixed 5-column
|
|
1079
|
-
template that stopped matching reality after seq + hash + agent
|
|
1080
|
-
badge got added; outcome pill landed in a 1fr cell and stretched.
|
|
1081
|
-
Flex with explicit ordering + a right-side ref cluster keeps the
|
|
1082
|
-
primary signal (badge / outcome / tool / summary) on the left. */
|
|
1083
|
-
#feed li {
|
|
1084
|
-
padding: 10px 24px;
|
|
1085
|
-
border-bottom: 1px solid var(--border-soft);
|
|
1086
|
-
display: flex;
|
|
1087
|
-
align-items: center;
|
|
1088
|
-
gap: 12px;
|
|
1089
|
-
transition: background 120ms ease;
|
|
1090
|
-
}
|
|
1091
|
-
#feed li:hover { background: rgba(255,255,255,0.02); }
|
|
1092
|
-
/* Seq + hash are FORENSIC-ONLY — both hidden by default per
|
|
1093
|
-
claim_user_persona HIGH (non-technical users primary). The
|
|
1094
|
-
"Advanced" toggle reveals BOTH columns together; without it
|
|
1095
|
-
/history is the simple agent feed a non-technical user expects. */
|
|
1096
|
-
#feed li .seq {
|
|
1097
|
-
order: 100;
|
|
1098
|
-
display: none;
|
|
1099
|
-
color: var(--fg-30); font-family: var(--mono); font-size: 10px;
|
|
1100
|
-
min-width: 42px; text-align: right;
|
|
1101
|
-
user-select: all;
|
|
1102
|
-
}
|
|
1103
|
-
#feed li .hash {
|
|
1104
|
-
order: 101;
|
|
1105
|
-
display: none;
|
|
1106
|
-
color: var(--fg-30); font-family: var(--mono); font-size: 10px;
|
|
1107
|
-
min-width: 60px;
|
|
1108
|
-
user-select: all;
|
|
1109
|
-
}
|
|
1110
|
-
body.advanced-on #feed li .seq,
|
|
1111
|
-
body.advanced-on #feed li .hash { display: inline-block; }
|
|
1112
|
-
/* Advanced toggle button — same visual shape as filter chips but its
|
|
1113
|
-
own .active state managed by setAdvanced (not syncChipUI). */
|
|
1114
|
-
.advanced-toggle {
|
|
1115
|
-
font-family: var(--mono); font-size: 11px;
|
|
1116
|
-
padding: 4px 12px; border-radius: 999px;
|
|
1117
|
-
background: rgba(255,255,255,0.03);
|
|
1118
|
-
color: var(--fg-50);
|
|
1119
|
-
border: 1px solid var(--border);
|
|
1120
|
-
cursor: pointer;
|
|
1121
|
-
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
|
1122
|
-
}
|
|
1123
|
-
.advanced-toggle:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1124
|
-
.advanced-toggle.active {
|
|
1125
|
-
background: var(--accent-wash);
|
|
1126
|
-
border-color: var(--accent);
|
|
1127
|
-
color: var(--accent);
|
|
1128
|
-
}
|
|
1129
|
-
/* Primary row order (left-to-right):
|
|
1130
|
-
agent badge (16px) — outcome pill — tool (60px) — summary (1fr) — score (50px) — seq — hash
|
|
1131
|
-
Flex order: badge=1, outcome=2, tool=3, summary=4 (flex:1), score=5,
|
|
1132
|
-
seq=100, hash=101 (right-side ref cluster). */
|
|
1133
|
-
#feed li .agent-badge { order: 1; flex: 0 0 16px; }
|
|
1134
|
-
#feed li .ts {
|
|
1135
|
-
order: 0; /* leftmost — chronological anchor */
|
|
1136
|
-
color: var(--fg-40); font-family: var(--mono); font-size: 11px;
|
|
1137
|
-
flex: 0 0 56px;
|
|
1138
|
-
white-space: nowrap;
|
|
1139
|
-
}
|
|
1140
|
-
#feed li .outcome {
|
|
1141
|
-
order: 2; flex: 0 0 auto;
|
|
1142
|
-
display: inline-block;
|
|
1143
|
-
font-family: var(--mono); font-size: 10.5px; font-weight: 600;
|
|
1144
|
-
text-transform: lowercase; letter-spacing: 0.04em;
|
|
1145
|
-
padding: 2px 10px; border-radius: 999px;
|
|
1146
|
-
text-align: center;
|
|
1147
|
-
border: 1px solid transparent;
|
|
1148
|
-
min-width: 56px;
|
|
1149
|
-
}
|
|
1150
|
-
#feed li .outcome.allow {
|
|
1151
|
-
color: var(--allow);
|
|
1152
|
-
background: rgba(40,200,64,0.08);
|
|
1153
|
-
border-color: rgba(40,200,64,0.20);
|
|
1154
|
-
}
|
|
1155
|
-
#feed li .outcome.block {
|
|
1156
|
-
color: var(--block);
|
|
1157
|
-
background: rgba(255,95,87,0.10);
|
|
1158
|
-
border-color: rgba(255,95,87,0.30);
|
|
1159
|
-
}
|
|
1160
|
-
#feed li .outcome.override {
|
|
1161
|
-
color: var(--warn);
|
|
1162
|
-
background: rgba(254,188,46,0.08);
|
|
1163
|
-
border-color: rgba(254,188,46,0.25);
|
|
1164
|
-
}
|
|
1165
|
-
#feed li .tool {
|
|
1166
|
-
order: 3; flex: 0 0 auto;
|
|
1167
|
-
color: var(--accent);
|
|
1168
|
-
font-family: var(--mono); font-size: 11.5px;
|
|
1169
|
-
min-width: 60px;
|
|
1170
|
-
}
|
|
1171
|
-
#feed li .summary {
|
|
1172
|
-
order: 4; flex: 1 1 auto;
|
|
1173
|
-
color: var(--fg-70);
|
|
1174
|
-
font-family: var(--mono); font-size: 11.5px;
|
|
1175
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1176
|
-
min-width: 0; /* allows ellipsis to engage in a flex child */
|
|
1177
|
-
}
|
|
1178
|
-
#feed li .score {
|
|
1179
|
-
order: 5; flex: 0 0 50px;
|
|
1180
|
-
text-align: right;
|
|
1181
|
-
font-family: var(--mono); font-size: 10.5px;
|
|
1182
|
-
color: var(--warn);
|
|
1183
|
-
}
|
|
1184
|
-
#feed li .score.zero { color: var(--fg-30); }
|
|
1185
|
-
#feed li .score.zero { color: var(--fg-30); }
|
|
1186
|
-
|
|
1187
|
-
/* P12 slice — tamper-evidence banner above the feed. Red, prominent,
|
|
1188
|
-
dismissible by reload (no manual close — chain breaks are sticky
|
|
1189
|
-
until verifyAuditChain returns valid=true again). */
|
|
1190
|
-
.tamper-banner {
|
|
1191
|
-
display: flex; align-items: center; gap: 10px;
|
|
1192
|
-
padding: 10px 14px; margin: 12px 0;
|
|
1193
|
-
border: 1px solid rgba(255,95,87,0.5);
|
|
1194
|
-
background: rgba(255,95,87,0.08);
|
|
1195
|
-
border-radius: 8px;
|
|
1196
|
-
color: var(--block);
|
|
1197
|
-
font-family: var(--mono); font-size: 12px;
|
|
1198
|
-
}
|
|
1199
|
-
/* Anomaly mode (seq_gap only — likely daemon restart, not tampering)
|
|
1200
|
-
gets warn-yellow not block-red. Reserves alarming red for the
|
|
1201
|
-
can't-be-benign case. */
|
|
1202
|
-
.tamper-banner[data-severity="anomaly"] {
|
|
1203
|
-
border-color: rgba(254,188,46,0.5);
|
|
1204
|
-
background: rgba(254,188,46,0.08);
|
|
1205
|
-
color: var(--warn);
|
|
1206
|
-
}
|
|
1207
|
-
/* Hide tamper banner entirely from non-technical users by default —
|
|
1208
|
-
it requires forensic vocabulary ("chain anomaly", "hash mismatch")
|
|
1209
|
-
to interpret. Surfaced when the Advanced toggle is on. */
|
|
1210
|
-
.tamper-banner { display: none !important; }
|
|
1211
|
-
body.advanced-on .tamper-banner[data-visible="true"] { display: flex !important; }
|
|
1212
|
-
|
|
1213
|
-
/* claim_user_persona — the More-filters group is hidden by default;
|
|
1214
|
-
body.more-filters-on reveals it. */
|
|
1215
|
-
.feed-filters.more-filters {
|
|
1216
|
-
display: none;
|
|
1217
|
-
padding-top: 0;
|
|
1218
|
-
margin-top: 8px;
|
|
1219
|
-
flex-wrap: wrap;
|
|
1220
|
-
gap: 6px;
|
|
1221
|
-
align-items: center;
|
|
1222
|
-
}
|
|
1223
|
-
body.more-filters-on .feed-filters.more-filters { display: flex; }
|
|
1224
|
-
.feed-filters .filter-label {
|
|
1225
|
-
font-family: var(--mono); font-size: 10.5px;
|
|
1226
|
-
color: var(--fg-40);
|
|
1227
|
-
margin-right: 2px;
|
|
1228
|
-
}
|
|
1229
|
-
.feed-filters .filter-spacer { flex: 1; }
|
|
1230
|
-
.tamper-banner .tb-icon { font-size: 14px; }
|
|
1231
|
-
.tamper-banner .tb-text { font-weight: 600; }
|
|
1232
|
-
.tamper-banner .tb-detail { color: var(--fg-70); font-size: 11px; }
|
|
1233
|
-
|
|
1234
|
-
/* ── Empty state ───────────────────────────────────────────────────── */
|
|
1235
|
-
#empty {
|
|
1236
|
-
padding: 64px 24px;
|
|
1237
|
-
text-align: center;
|
|
1238
|
-
color: var(--fg-50);
|
|
1239
|
-
}
|
|
1240
|
-
#empty .pill {
|
|
1241
|
-
display: inline-block;
|
|
1242
|
-
padding: 6px 14px;
|
|
1243
|
-
border: 1px solid var(--border);
|
|
1244
|
-
border-radius: 999px;
|
|
1245
|
-
background: rgba(255,255,255,0.03);
|
|
1246
|
-
font-family: var(--mono); font-size: 11px;
|
|
1247
|
-
color: var(--fg-70);
|
|
1248
|
-
margin-bottom: 14px;
|
|
1249
|
-
}
|
|
1250
|
-
#empty .hint {
|
|
1251
|
-
margin-top: 10px;
|
|
1252
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-40);
|
|
1253
|
-
}
|
|
1254
|
-
code {
|
|
1255
|
-
font-family: var(--mono);
|
|
1256
|
-
background: rgba(255,255,255,0.05);
|
|
1257
|
-
padding: 1px 6px; border-radius: 4px;
|
|
1258
|
-
color: var(--fg);
|
|
1259
|
-
}
|
|
1260
|
-
</style>
|
|
1261
|
-
</head>
|
|
1262
|
-
<body>
|
|
1263
|
-
<nav class="floating-nav">
|
|
1264
|
-
<a href="/" class="logo">codehere</a>
|
|
1265
|
-
<a href="/" class="nav-link">Tasks</a>
|
|
1266
|
-
<a href="/threads" class="nav-link">Threads</a>
|
|
1267
|
-
<a href="/history" class="nav-link active">History</a>
|
|
1268
|
-
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
1269
|
-
<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>
|
|
1270
|
-
</nav>
|
|
1271
|
-
|
|
1272
|
-
<div class="tagline" style="margin-top:90px">
|
|
1273
|
-
One feed. Every agent. Every action.
|
|
1274
|
-
<span class="conn-inline" style="margin-left:12px;font-family:var(--mono);font-size:10px;color:var(--fg-40);">
|
|
1275
|
-
<span id="live-dot" class="dot"></span><span id="conn">connecting</span>
|
|
1276
|
-
</span>
|
|
1277
|
-
</div>
|
|
1278
|
-
|
|
1279
|
-
<div class="status">
|
|
1280
|
-
<div class="stat">
|
|
1281
|
-
<span class="label">Sessions</span>
|
|
1282
|
-
<span class="value live" id="stat-sessions">0</span>
|
|
1283
|
-
</div>
|
|
1284
|
-
<span class="sep">·</span>
|
|
1285
|
-
<div class="stat blocked">
|
|
1286
|
-
<span class="label">Blocked</span>
|
|
1287
|
-
<span class="value" id="stat-blocked">0</span>
|
|
1288
|
-
</div>
|
|
1289
|
-
<span class="sep">·</span>
|
|
1290
|
-
<div class="stat">
|
|
1291
|
-
<span class="label">Events</span>
|
|
1292
|
-
<span class="value" id="stat-events">0</span>
|
|
1293
|
-
<span class="label">past hour</span>
|
|
1294
|
-
</div>
|
|
1295
|
-
<span class="sep">·</span>
|
|
1296
|
-
<div class="stat latest">
|
|
1297
|
-
<span class="label">Latest</span>
|
|
1298
|
-
<span class="value" id="stat-latest">—</span>
|
|
1299
|
-
<span class="ago" id="stat-latest-ago"></span>
|
|
1300
|
-
</div>
|
|
1301
|
-
</div>
|
|
1302
|
-
|
|
1303
|
-
<div class="section-label">Audit feed · Live</div>
|
|
1304
|
-
<main>
|
|
1305
|
-
<!-- Bug A5 — filter chip bar. Wall-of-text feed with no grouping/filter
|
|
1306
|
-
made the page hard to scan; chips toggle visibility per outcome
|
|
1307
|
-
(allow/block/override) and per tool family. State lives in URL hash
|
|
1308
|
-
so reload preserves the active filter. -->
|
|
1309
|
-
<!-- claim_user_persona HIGH — non-technical users primary. Default
|
|
1310
|
-
bar: outcome filters only (4 buttons). Per-tool + per-agent chips
|
|
1311
|
-
behind the .more-filters group; .advanced-toggle gates seq + hash.
|
|
1312
|
-
Reduces default surface count from 14 to 4 while keeping power
|
|
1313
|
-
features one click away for technical users. -->
|
|
1314
|
-
<div id="feed-filters" class="feed-filters" role="tablist">
|
|
1315
|
-
<button class="filter-chip active" data-filter="outcome" data-value="all" type="button">All</button>
|
|
1316
|
-
<button class="filter-chip" data-filter="outcome" data-value="block" type="button">Blocked</button>
|
|
1317
|
-
<button class="filter-chip" data-filter="outcome" data-value="override" type="button">Overridden</button>
|
|
1318
|
-
<button class="filter-chip filter-chip-clear" data-filter="clear" type="button">Clear</button>
|
|
1319
|
-
<span class="filter-spacer"></span>
|
|
1320
|
-
<button class="advanced-toggle" id="more-filters-toggle" type="button" title="Show per-tool, per-agent, and forensic filters">More ▾</button>
|
|
1321
|
-
<button class="advanced-toggle" id="advanced-toggle" type="button" title="Show chain seq + hash columns (forensic)">Advanced</button>
|
|
1322
|
-
</div>
|
|
1323
|
-
<!-- Disclosure group — hidden by default. body.more-filters-on
|
|
1324
|
-
reveals the row. -->
|
|
1325
|
-
<div id="more-filters" class="feed-filters more-filters" role="tablist">
|
|
1326
|
-
<span class="filter-label">Tool:</span>
|
|
1327
|
-
<button class="filter-chip" data-filter="tool" data-value="Bash" type="button">Bash</button>
|
|
1328
|
-
<button class="filter-chip" data-filter="tool" data-value="Edit" type="button">Edit</button>
|
|
1329
|
-
<button class="filter-chip" data-filter="tool" data-value="Write" type="button">Write</button>
|
|
1330
|
-
<button class="filter-chip" data-filter="tool" data-value="Read" type="button">Read</button>
|
|
1331
|
-
<button class="filter-chip" data-filter="tool" data-value="pre_prompt" type="button" title="Pre-spawn safety scans">Pre-prompt</button>
|
|
1332
|
-
<span class="filter-sep">·</span>
|
|
1333
|
-
<span class="filter-label">Agent:</span>
|
|
1334
|
-
<button class="filter-chip" data-filter="agent" data-value="claude-code" type="button">Claude Code</button>
|
|
1335
|
-
<button class="filter-chip" data-filter="agent" data-value="codex" type="button">Codex</button>
|
|
1336
|
-
<button class="filter-chip" data-filter="agent" data-value="aider" type="button">Aider</button>
|
|
1337
|
-
<button class="filter-chip" data-filter="agent" data-value="gemini" type="button">Gemini</button>
|
|
1338
|
-
<button class="filter-chip" data-filter="agent" data-value="conductor" type="button">Conductor</button>
|
|
1339
|
-
</div>
|
|
1340
|
-
<!-- P12 slice — tamper-evidence banner. Shown only when /api/audit/verify
|
|
1341
|
-
returns valid=false. Hash-chain integrity is the load-bearing
|
|
1342
|
-
differentiator for codehere's audit log; surfacing chain breaks is
|
|
1343
|
-
the user-visible payoff. -->
|
|
1344
|
-
<div id="tamper-banner" class="tamper-banner" style="display:none">
|
|
1345
|
-
<span class="tb-icon">⚠</span>
|
|
1346
|
-
<span class="tb-text">Audit log tampering detected.</span>
|
|
1347
|
-
<span class="tb-detail" id="tb-detail"></span>
|
|
1348
|
-
</div>
|
|
1349
|
-
<ul id="feed"></ul>
|
|
1350
|
-
<div id="empty">
|
|
1351
|
-
<div class="pill">No tool calls yet</div>
|
|
1352
|
-
<div class="hint">
|
|
1353
|
-
Install the hook → <code>ch hook install</code> — then any Claude Code, Cursor, or Codex tool call will appear here live.
|
|
1354
|
-
</div>
|
|
1355
|
-
</div>
|
|
1356
|
-
</main>
|
|
1357
|
-
|
|
1358
|
-
<script>
|
|
1359
|
-
${AGENT_BADGE_JS}
|
|
1360
|
-
|
|
1361
|
-
(function () {
|
|
1362
|
-
const feed = document.getElementById('feed');
|
|
1363
|
-
const empty = document.getElementById('empty');
|
|
1364
|
-
// UX-simplification iter 3 (May 7): dropped countEl reference — the
|
|
1365
|
-
// #count element doesn't exist in HISTORY markup. countEl.textContent
|
|
1366
|
-
// threw TypeError on every render, eating the next line that hides
|
|
1367
|
-
// the empty-state. Result: visible "No tool calls yet" message AND
|
|
1368
|
-
// 50 visible feed entries simultaneously. Latent bug since the
|
|
1369
|
-
// history view shipped.
|
|
1370
|
-
const connEl = document.getElementById('conn');
|
|
1371
|
-
const dot = document.getElementById('live-dot');
|
|
1372
|
-
|
|
1373
|
-
function shortPath(p) {
|
|
1374
|
-
const parts = String(p).split('/');
|
|
1375
|
-
return parts.length <= 2 ? p : '…/' + parts.slice(-2).join('/');
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
function summarize(entry) {
|
|
1379
|
-
if (entry.reason) return String(entry.reason);
|
|
1380
|
-
if (entry.matched) return String(entry.matched);
|
|
1381
|
-
|
|
1382
|
-
const raw = entry.toolInput;
|
|
1383
|
-
if (!raw) return '';
|
|
1384
|
-
|
|
1385
|
-
try {
|
|
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
|
-
}
|
|
1394
|
-
if (input.command) return String(input.command).replace(/\\n/g, ' ').slice(0, 140);
|
|
1395
|
-
if (input.file_path) return shortPath(String(input.file_path));
|
|
1396
|
-
if (Array.isArray(input.edits) && input.edits[0] && input.edits[0].file_path) {
|
|
1397
|
-
return input.edits.length + ' edits → ' + shortPath(String(input.edits[0].file_path));
|
|
1398
|
-
}
|
|
1399
|
-
if (input.query) return String(input.query).slice(0, 140);
|
|
1400
|
-
if (input.url) return String(input.url);
|
|
1401
|
-
if (input.pattern) return 'grep ' + String(input.pattern).slice(0, 120);
|
|
1402
|
-
if (input.path) return shortPath(String(input.path));
|
|
1403
|
-
} catch { /* truncated JSON — regex fallback below */ }
|
|
1404
|
-
|
|
1405
|
-
const s = String(raw);
|
|
1406
|
-
const fp = s.match(/"file_path"\\s*:\\s*"([^"]+)"/);
|
|
1407
|
-
if (fp) return shortPath(fp[1]);
|
|
1408
|
-
const cmd = s.match(/"command"\\s*:\\s*"([^"]{0,140})"/);
|
|
1409
|
-
if (cmd) return cmd[1].replace(/\\\\n/g, ' ');
|
|
1410
|
-
|
|
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 '';
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
function escape(s) {
|
|
1417
|
-
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
function render(entry) {
|
|
1421
|
-
const ts = new Date(entry.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
1422
|
-
const blocked = Boolean(entry.blocked);
|
|
1423
|
-
// Bug A5 — derive a 3-way outcome (allow / block / override) and stamp
|
|
1424
|
-
// it as a data-attribute so the filter chips can hide non-matching rows
|
|
1425
|
-
// without re-rendering the feed.
|
|
1426
|
-
const isOverride = Boolean(entry.overrideReason);
|
|
1427
|
-
const outcome = isOverride ? 'override' : (blocked ? 'block' : 'allow');
|
|
1428
|
-
const tool = String(entry.toolName || entry.tool_name || '—');
|
|
1429
|
-
const score = entry.riskScore ?? entry.risk_score ?? 0;
|
|
1430
|
-
const summary = summarize(entry);
|
|
1431
|
-
// Cross-vendor audit-mirror (#110 slice 2-4) writes the originating
|
|
1432
|
-
// agent inside toolInput as JSON. Pull it for the per-row badge so
|
|
1433
|
-
// users can tell at a glance whether codex / aider / gemini / etc.
|
|
1434
|
-
// ran each call. Falls back to no-badge when toolInput isn't JSON
|
|
1435
|
-
// (legacy entries pre-mirror — Claude PreToolUse rows).
|
|
1436
|
-
let originAgent = '';
|
|
1437
|
-
try {
|
|
1438
|
-
const parsed = typeof entry.toolInput === 'string' ? JSON.parse(entry.toolInput) : entry.toolInput;
|
|
1439
|
-
if (parsed && typeof parsed === 'object' && typeof parsed.agent === 'string') {
|
|
1440
|
-
originAgent = parsed.agent;
|
|
1441
|
-
}
|
|
1442
|
-
} catch { /* not JSON — leave blank */ }
|
|
1443
|
-
|
|
1444
|
-
// P12 follow-up — surface the chain seq number per row. Forensic
|
|
1445
|
-
// value: a reviewer can refer to a specific entry by its seq + the
|
|
1446
|
-
// hash banner up top makes it easy to verify integrity. Rows with
|
|
1447
|
-
// no seq (legacy entries) get a muted dash.
|
|
1448
|
-
const seq = (typeof entry.seq === 'number') ? entry.seq : null;
|
|
1449
|
-
const entryHash = (typeof entry.entryHash === 'string') ? entry.entryHash : null;
|
|
1450
|
-
|
|
1451
|
-
const li = document.createElement('li');
|
|
1452
|
-
li.dataset.outcome = outcome;
|
|
1453
|
-
li.dataset.tool = tool;
|
|
1454
|
-
if (originAgent) li.dataset.agent = originAgent;
|
|
1455
|
-
if (seq !== null) li.dataset.seq = String(seq);
|
|
1456
|
-
if (entryHash) li.dataset.hash = entryHash;
|
|
1457
|
-
// P12 advanced — short hash (8 chars) revealed by the Advanced toggle.
|
|
1458
|
-
// Stored on the row regardless; CSS hides it by default.
|
|
1459
|
-
const shortHash = entryHash ? entryHash.slice(0, 8) : '—';
|
|
1460
|
-
li.innerHTML =
|
|
1461
|
-
'<span class="seq" title="Chain sequence number">' + (seq !== null ? '#' + seq : '—') + '</span>' +
|
|
1462
|
-
'<span class="hash" title="' + (entryHash ? 'entryHash: ' + entryHash : 'no chain hash') + '">' + shortHash + '</span>' +
|
|
1463
|
-
'<span class="ts">' + escape(ts) + '</span>' +
|
|
1464
|
-
(originAgent ? agentBadgeHTML(originAgent) : '') +
|
|
1465
|
-
'<span class="outcome ' + outcome + '">' + outcome + '</span>' +
|
|
1466
|
-
'<span class="tool">' + escape(tool) + '</span>' +
|
|
1467
|
-
(summary ? '<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' : '') +
|
|
1468
|
-
'<span class="score ' + (score ? '' : 'zero') + '">' + (score ? score : '—') + '</span>';
|
|
1469
|
-
feed.insertBefore(li, feed.firstChild);
|
|
1470
|
-
applyFilterToRow(li);
|
|
1471
|
-
empty.style.display = 'none';
|
|
1472
|
-
// v0.6 — cap DOM rows so a long-open /history tab doesn't accumulate
|
|
1473
|
-
// thousands of <li>s (~50 events/min × hours = browser slowdown).
|
|
1474
|
-
// Same cap-pattern as /threads (turns) and dashboard (sessions).
|
|
1475
|
-
// Newest-first prepend means the OLDEST is the LAST child; trim that.
|
|
1476
|
-
const MAX_FEED_ROWS = 500;
|
|
1477
|
-
while (feed.children.length > MAX_FEED_ROWS) {
|
|
1478
|
-
feed.removeChild(feed.lastChild);
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
// Bug A5 — filter state. Active outcome filter and active tool filter
|
|
1483
|
-
// (each null means "no filter"). Persisted in the URL hash so a
|
|
1484
|
-
// bookmarkable view is achievable + reload preserves the filter.
|
|
1485
|
-
const filterState = { outcome: null, tool: null };
|
|
1486
|
-
function readFilterFromHash() {
|
|
1487
|
-
const hash = (location.hash || '').replace(/^#/, '');
|
|
1488
|
-
if (!hash) return;
|
|
1489
|
-
const params = new URLSearchParams(hash);
|
|
1490
|
-
const o = params.get('outcome');
|
|
1491
|
-
const t = params.get('tool');
|
|
1492
|
-
if (o && o !== 'all') filterState.outcome = o;
|
|
1493
|
-
if (t) filterState.tool = t;
|
|
1494
|
-
}
|
|
1495
|
-
function writeFilterToHash() {
|
|
1496
|
-
const params = new URLSearchParams();
|
|
1497
|
-
if (filterState.outcome) params.set('outcome', filterState.outcome);
|
|
1498
|
-
if (filterState.tool) params.set('tool', filterState.tool);
|
|
1499
|
-
const next = params.toString();
|
|
1500
|
-
location.hash = next ? '#' + next : '';
|
|
1501
|
-
}
|
|
1502
|
-
function rowMatches(li) {
|
|
1503
|
-
const o = li.dataset.outcome || '';
|
|
1504
|
-
const t = li.dataset.tool || '';
|
|
1505
|
-
if (filterState.outcome && o !== filterState.outcome) return false;
|
|
1506
|
-
if (filterState.tool && t !== filterState.tool) return false;
|
|
1507
|
-
return true;
|
|
1508
|
-
}
|
|
1509
|
-
function applyFilterToRow(li) {
|
|
1510
|
-
if (rowMatches(li)) li.classList.add('feed-match');
|
|
1511
|
-
else li.classList.remove('feed-match');
|
|
1512
|
-
}
|
|
1513
|
-
function applyFilterToAllRows() {
|
|
1514
|
-
const active = !!(filterState.outcome || filterState.tool);
|
|
1515
|
-
feed.classList.toggle('filtered', active);
|
|
1516
|
-
Array.prototype.forEach.call(feed.querySelectorAll('li'), applyFilterToRow);
|
|
1517
|
-
}
|
|
1518
|
-
function syncChipUI() {
|
|
1519
|
-
const chips = document.querySelectorAll('#feed-filters .filter-chip');
|
|
1520
|
-
Array.prototype.forEach.call(chips, function(chip) {
|
|
1521
|
-
const fk = chip.dataset.filter;
|
|
1522
|
-
const fv = chip.dataset.value;
|
|
1523
|
-
if (fk === 'clear') return;
|
|
1524
|
-
let isActive = false;
|
|
1525
|
-
if (fk === 'outcome') {
|
|
1526
|
-
isActive = (fv === 'all' && !filterState.outcome) || (fv !== 'all' && filterState.outcome === fv);
|
|
1527
|
-
} else if (fk === 'tool') {
|
|
1528
|
-
isActive = filterState.tool === fv;
|
|
1529
|
-
}
|
|
1530
|
-
chip.classList.toggle('active', isActive);
|
|
1531
|
-
});
|
|
1532
|
-
}
|
|
1533
|
-
document.addEventListener('click', function(ev) {
|
|
1534
|
-
const chip = ev.target && ev.target.closest && ev.target.closest('.filter-chip');
|
|
1535
|
-
if (!chip) return;
|
|
1536
|
-
const fk = chip.dataset.filter;
|
|
1537
|
-
const fv = chip.dataset.value;
|
|
1538
|
-
if (fk === 'clear') {
|
|
1539
|
-
filterState.outcome = null;
|
|
1540
|
-
filterState.tool = null;
|
|
1541
|
-
} else if (fk === 'outcome') {
|
|
1542
|
-
filterState.outcome = fv === 'all' ? null : fv;
|
|
1543
|
-
} else if (fk === 'tool') {
|
|
1544
|
-
// Toggle: clicking an already-active tool chip clears the tool filter.
|
|
1545
|
-
filterState.tool = filterState.tool === fv ? null : fv;
|
|
1546
|
-
}
|
|
1547
|
-
writeFilterToHash();
|
|
1548
|
-
syncChipUI();
|
|
1549
|
-
applyFilterToAllRows();
|
|
1550
|
-
});
|
|
1551
|
-
// Init from URL hash
|
|
1552
|
-
readFilterFromHash();
|
|
1553
|
-
syncChipUI();
|
|
1554
|
-
|
|
1555
|
-
// ── Status strip (answers C/A/B/D) ───────────────────────────────────
|
|
1556
|
-
const events = []; // accumulate for rolling-window stats
|
|
1557
|
-
const HOUR_MS = 60 * 60 * 1000;
|
|
1558
|
-
|
|
1559
|
-
function parseTs(entry) {
|
|
1560
|
-
const t = entry.timestamp;
|
|
1561
|
-
if (typeof t === 'number') return t;
|
|
1562
|
-
if (typeof t === 'string') { const n = Date.parse(t); return isNaN(n) ? Date.now() : n; }
|
|
1563
|
-
return Date.now();
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
function agoString(ms) {
|
|
1567
|
-
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
1568
|
-
if (s < 60) return s + 's ago';
|
|
1569
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
1570
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
1571
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
function recomputeStats() {
|
|
1575
|
-
const now = Date.now();
|
|
1576
|
-
const cutoff = now - HOUR_MS;
|
|
1577
|
-
const inWindow = events.filter(e => e._ts >= cutoff);
|
|
1578
|
-
const sessions = new Set();
|
|
1579
|
-
let blocked = 0;
|
|
1580
|
-
for (const e of inWindow) {
|
|
1581
|
-
if (e.sessionId) sessions.add(e.sessionId);
|
|
1582
|
-
if (e.blocked) blocked += 1;
|
|
1583
|
-
}
|
|
1584
|
-
document.getElementById('stat-sessions').textContent = String(sessions.size);
|
|
1585
|
-
const blockedEl = document.getElementById('stat-blocked');
|
|
1586
|
-
blockedEl.textContent = String(blocked);
|
|
1587
|
-
blockedEl.classList.toggle('nonzero', blocked > 0);
|
|
1588
|
-
document.getElementById('stat-events').textContent = String(inWindow.length);
|
|
1589
|
-
|
|
1590
|
-
const latest = events[events.length - 1];
|
|
1591
|
-
if (latest) {
|
|
1592
|
-
const tool = String(latest.toolName || 'tool');
|
|
1593
|
-
const sum = summarize(latest);
|
|
1594
|
-
document.getElementById('stat-latest').textContent = tool + ' · ' + sum;
|
|
1595
|
-
document.getElementById('stat-latest-ago').textContent = agoString(latest._ts);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
const es = new EventSource('/events');
|
|
1600
|
-
es.onopen = () => { dot.classList.add('live'); connEl.textContent = 'live'; };
|
|
1601
|
-
es.onerror = () => { dot.classList.remove('live'); connEl.textContent = 'reconnecting'; };
|
|
1602
|
-
es.onmessage = (ev) => {
|
|
1603
|
-
try {
|
|
1604
|
-
const entry = JSON.parse(ev.data);
|
|
1605
|
-
entry._ts = parseTs(entry);
|
|
1606
|
-
events.push(entry);
|
|
1607
|
-
// v0.6 — paired with the DOM cap above. Stats only need recent
|
|
1608
|
-
// events; trimming the head keeps recomputeStats O(MAX) not O(forever).
|
|
1609
|
-
const MAX_EVENTS_IN_MEMORY = 500;
|
|
1610
|
-
if (events.length > MAX_EVENTS_IN_MEMORY) {
|
|
1611
|
-
events.splice(0, events.length - MAX_EVENTS_IN_MEMORY);
|
|
1612
|
-
}
|
|
1613
|
-
render(entry);
|
|
1614
|
-
recomputeStats();
|
|
1615
|
-
} catch { /* skip malformed */ }
|
|
1616
|
-
};
|
|
1617
|
-
|
|
1618
|
-
// Keep "Xs/Xm ago" fresh even when no new events arrive
|
|
1619
|
-
setInterval(() => { if (events.length) recomputeStats(); }, 1000);
|
|
1620
|
-
|
|
1621
|
-
// P12 slice — poll /api/audit/verify every 60s; show the tamper
|
|
1622
|
-
// banner if the hash chain breaks. First poll runs at load.
|
|
1623
|
-
function pollAuditChain() {
|
|
1624
|
-
fetch('/api/audit/verify')
|
|
1625
|
-
.then(r => r.ok ? r.json() : null)
|
|
1626
|
-
.then(d => {
|
|
1627
|
-
if (!d) return;
|
|
1628
|
-
const banner = document.getElementById('tamper-banner');
|
|
1629
|
-
const detail = document.getElementById('tb-detail');
|
|
1630
|
-
if (!banner) return;
|
|
1631
|
-
if (d.valid) {
|
|
1632
|
-
banner.dataset.visible = 'false';
|
|
1633
|
-
return;
|
|
1634
|
-
}
|
|
1635
|
-
// claim_user_persona HIGH — banner hidden from non-technical
|
|
1636
|
-
// users (CSS gates on body.advanced-on). data-visible flag
|
|
1637
|
-
// tells CSS whether to show when advanced toggle flips on.
|
|
1638
|
-
// Severity-driven copy below stays for technical readers.
|
|
1639
|
-
const errs = Array.isArray(d.errors) ? d.errors : [];
|
|
1640
|
-
const hasHashMismatch = errs.some(function(e) { return e.reason === 'hash_mismatch' || e.reason === 'parse_failure'; });
|
|
1641
|
-
const titleEl = banner.querySelector('.tb-text');
|
|
1642
|
-
const iconEl = banner.querySelector('.tb-icon');
|
|
1643
|
-
if (hasHashMismatch) {
|
|
1644
|
-
banner.dataset.severity = 'tamper';
|
|
1645
|
-
if (titleEl) titleEl.textContent = 'Audit log tampering detected.';
|
|
1646
|
-
if (iconEl) iconEl.textContent = '⚠';
|
|
1647
|
-
} else {
|
|
1648
|
-
banner.dataset.severity = 'anomaly';
|
|
1649
|
-
if (titleEl) titleEl.textContent = 'Audit chain anomaly (likely a daemon restart).';
|
|
1650
|
-
if (iconEl) iconEl.textContent = 'ℹ';
|
|
1651
|
-
}
|
|
1652
|
-
banner.dataset.visible = 'true';
|
|
1653
|
-
if (detail && errs.length > 0) {
|
|
1654
|
-
const first = errs[0];
|
|
1655
|
-
detail.textContent = '· ' + (errs.length === 1
|
|
1656
|
-
? 'line ' + first.lineNumber + ': ' + first.reason
|
|
1657
|
-
: errs.length + ' issues (first at line ' + first.lineNumber + ')');
|
|
1658
|
-
}
|
|
1659
|
-
})
|
|
1660
|
-
.catch(() => { /* leave banner state */ });
|
|
1661
|
-
}
|
|
1662
|
-
pollAuditChain();
|
|
1663
|
-
setInterval(pollAuditChain, 60000);
|
|
1664
|
-
|
|
1665
|
-
// P12 advanced toggle — flip body.advanced-on. Persisted via the URL
|
|
1666
|
-
// hash (?advanced=1) so reload preserves. Click toggles back off.
|
|
1667
|
-
const advancedToggle = document.getElementById('advanced-toggle');
|
|
1668
|
-
function setAdvanced(on) {
|
|
1669
|
-
document.body.classList.toggle('advanced-on', !!on);
|
|
1670
|
-
if (advancedToggle) advancedToggle.classList.toggle('active', !!on);
|
|
1671
|
-
try {
|
|
1672
|
-
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1673
|
-
if (on) params.set('advanced', '1'); else params.delete('advanced');
|
|
1674
|
-
const next = params.toString();
|
|
1675
|
-
location.hash = next ? '#' + next : '';
|
|
1676
|
-
} catch { /* hash write best-effort */ }
|
|
1677
|
-
}
|
|
1678
|
-
// Hash-on-load read
|
|
1679
|
-
try {
|
|
1680
|
-
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1681
|
-
if (params.get('advanced') === '1') setAdvanced(true);
|
|
1682
|
-
if (params.get('more') === '1') setMoreFilters(true);
|
|
1683
|
-
} catch { /* ignore */ }
|
|
1684
|
-
if (advancedToggle) advancedToggle.addEventListener('click', function() {
|
|
1685
|
-
setAdvanced(!document.body.classList.contains('advanced-on'));
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
|
-
// claim_user_persona HIGH — More-filters disclosure for the per-tool
|
|
1689
|
-
// + per-agent chips. Default closed so non-technical users see 4
|
|
1690
|
-
// chips instead of 14.
|
|
1691
|
-
const moreToggle = document.getElementById('more-filters-toggle');
|
|
1692
|
-
function setMoreFilters(on) {
|
|
1693
|
-
document.body.classList.toggle('more-filters-on', !!on);
|
|
1694
|
-
if (moreToggle) moreToggle.classList.toggle('active', !!on);
|
|
1695
|
-
try {
|
|
1696
|
-
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1697
|
-
if (on) params.set('more', '1'); else params.delete('more');
|
|
1698
|
-
const next = params.toString();
|
|
1699
|
-
location.hash = next ? '#' + next : '';
|
|
1700
|
-
} catch { /* hash write best-effort */ }
|
|
1701
|
-
}
|
|
1702
|
-
if (moreToggle) moreToggle.addEventListener('click', function() {
|
|
1703
|
-
setMoreFilters(!document.body.classList.contains('more-filters-on'));
|
|
1704
|
-
});
|
|
1705
|
-
})();
|
|
1706
|
-
</script>
|
|
1707
|
-
</body>
|
|
1708
|
-
</html>`;
|
|
1709
|
-
/**
|
|
1710
|
-
* Project thread view — v0.6.0 M1 phase 1.
|
|
1711
|
-
*
|
|
1712
|
-
* Reads from /api/projects + /api/projects/:slug (shipped in 397a66a) and
|
|
1713
|
-
* renders a single per-project thread instead of session-card-per-task.
|
|
1714
|
-
* Phase 2 (next) promotes this to the dashboard default; this phase ships
|
|
1715
|
-
* it as a navigable surface so it can be tasted without breaking the
|
|
1716
|
-
* existing dashboard.
|
|
1717
|
-
*
|
|
1718
|
-
* Polling: 5s interval (matches existing /api/tasks polling cadence).
|
|
1719
|
-
* SSE upgrade is a v0.6.x patch.
|
|
1720
|
-
*/
|
|
1721
|
-
export const THREAD_HTML = `<!doctype html>
|
|
1722
|
-
<html lang="en">
|
|
1723
|
-
<head>
|
|
1724
|
-
<meta charset="utf-8" />
|
|
1725
|
-
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1726
|
-
<title>codehere · Threads</title>
|
|
1727
|
-
<style>
|
|
1728
|
-
${SHARED_STYLES}
|
|
1729
|
-
* { box-sizing: border-box; }
|
|
1730
|
-
html, body { height: 100%; }
|
|
1731
|
-
body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
|
|
1732
|
-
|
|
1733
|
-
/* Floating nav inherits from SHARED_STYLES (no override here — keeps the
|
|
1734
|
-
active-state look identical to / and /history. Founder caught
|
|
1735
|
-
2026-05-03 that the local override gave /threads a different orange-bg
|
|
1736
|
-
active state, fragmenting the design system across pages.) */
|
|
1737
|
-
|
|
1738
|
-
/* ── Slack-shape shell (3-column — channels | thread | members) ─────── */
|
|
1739
|
-
.shell.slack-shape {
|
|
1740
|
-
max-width: 1440px;
|
|
1741
|
-
margin: 90px auto 24px;
|
|
1742
|
-
padding: 0 24px;
|
|
1743
|
-
display: grid;
|
|
1744
|
-
grid-template-columns: 200px 1fr 220px;
|
|
1745
|
-
gap: 16px;
|
|
1746
|
-
align-items: start;
|
|
1747
|
-
}
|
|
1748
|
-
@media (max-width: 1100px) {
|
|
1749
|
-
.shell.slack-shape { grid-template-columns: 1fr; }
|
|
1750
|
-
.shell.slack-shape .channels-pane, .shell.slack-shape .members-pane {
|
|
1751
|
-
position: static; order: 99;
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
.channels-pane, .members-pane {
|
|
1755
|
-
background: rgba(255,255,255,0.025);
|
|
1756
|
-
border: 1px solid var(--border-soft);
|
|
1757
|
-
border-radius: 10px;
|
|
1758
|
-
padding: 14px 12px;
|
|
1759
|
-
position: sticky; top: 88px;
|
|
1760
|
-
font-family: var(--mono);
|
|
1761
|
-
z-index: 1;
|
|
1762
|
-
}
|
|
1763
|
-
.pane-head {
|
|
1764
|
-
font-size: 9.5px; letter-spacing: 0.16em;
|
|
1765
|
-
color: var(--fg-30); margin-bottom: 10px;
|
|
1766
|
-
text-transform: uppercase;
|
|
1767
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
1768
|
-
}
|
|
1769
|
-
.pane-head .pane-count { color: var(--fg-50); }
|
|
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
|
-
margin-bottom: 2px;
|
|
1778
|
-
}
|
|
1779
|
-
.channel:hover, .member:hover { background: rgba(255,255,255,0.04); color: var(--fg); }
|
|
1780
|
-
.channel.active { background: rgba(255,154,118,0.08); color: var(--fg); border-color: rgba(255,154,118,0.25); }
|
|
1781
|
-
.channel-glyph, .member-presence {
|
|
1782
|
-
color: var(--fg-40); flex: 0 0 14px; text-align: center;
|
|
1783
|
-
}
|
|
1784
|
-
.member-presence.ready { color: var(--allow); }
|
|
1785
|
-
.member-presence.warn { color: var(--warn); }
|
|
1786
|
-
.member-presence.offline { color: var(--fg-30); }
|
|
1787
|
-
.channel-name, .member-label {
|
|
1788
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1789
|
-
flex: 1; min-width: 0;
|
|
1790
|
-
}
|
|
1791
|
-
.member-cwd-hint, .channel-cwd-hint {
|
|
1792
|
-
font-size: 9.5px; color: var(--fg-30);
|
|
1793
|
-
margin-top: 4px; padding-left: 22px;
|
|
1794
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1795
|
-
}
|
|
1796
|
-
/* Pencil affordance for project rename. Always visible at subtle opacity;
|
|
1797
|
-
brightens only when the user hovers the pencil itself. NO background
|
|
1798
|
-
box at any state. Inline SVG so it renders crisp at any size and
|
|
1799
|
-
inherits color from CSS. */
|
|
1800
|
-
.channel-rename {
|
|
1801
|
-
background: transparent; border: none; cursor: pointer;
|
|
1802
|
-
color: var(--fg-30); padding: 4px;
|
|
1803
|
-
transition: color 0.12s ease;
|
|
1804
|
-
flex: 0 0 auto;
|
|
1805
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
1806
|
-
}
|
|
1807
|
-
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
1808
|
-
.channel-rename:hover { color: var(--fg); background: transparent; }
|
|
1809
|
-
|
|
1810
|
-
/* Inline edit-in-place for project rename. May 6 2026: replaces the
|
|
1811
|
-
prior window.prompt() browser dialog (cheap UX; not enterprise-grade
|
|
1812
|
-
per Open-Agents reference). Input matches channel-name typography so
|
|
1813
|
-
the swap feels seamless. */
|
|
1814
|
-
.channel-rename-input {
|
|
1815
|
-
background: transparent;
|
|
1816
|
-
border: 1px solid var(--accent);
|
|
1817
|
-
border-radius: 3px;
|
|
1818
|
-
color: var(--fg);
|
|
1819
|
-
font: inherit;
|
|
1820
|
-
font-weight: 500;
|
|
1821
|
-
padding: 1px 4px;
|
|
1822
|
-
margin: -2px -5px;
|
|
1823
|
-
width: calc(100% + 10px);
|
|
1824
|
-
outline: none;
|
|
1825
|
-
box-sizing: border-box;
|
|
1826
|
-
}
|
|
1827
|
-
.channel-rename-input.error {
|
|
1828
|
-
border-color: var(--block);
|
|
1829
|
-
animation: rename-shake 0.25s ease;
|
|
1830
|
-
}
|
|
1831
|
-
@keyframes rename-shake {
|
|
1832
|
-
0%, 100% { transform: translateX(0); }
|
|
1833
|
-
25% { transform: translateX(-2px); }
|
|
1834
|
-
75% { transform: translateX(2px); }
|
|
1835
|
-
}
|
|
1836
|
-
.pane-empty {
|
|
1837
|
-
color: var(--fg-30); font-size: 10.5px;
|
|
1838
|
-
padding: 8px; text-align: center;
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
/* ── Thread pane (center) ───────────────────────────────────────────── */
|
|
1842
|
-
.thread-pane { min-width: 0; }
|
|
1843
|
-
|
|
1844
|
-
.meta-panel {
|
|
1845
|
-
border: 1px solid var(--border); background: rgba(255,255,255,0.02);
|
|
1846
|
-
border-radius: 10px; padding: 18px 20px; margin-bottom: 16px;
|
|
1847
|
-
}
|
|
1848
|
-
.meta-panel h1 {
|
|
1849
|
-
font-size: 22px; font-weight: 600; color: var(--fg);
|
|
1850
|
-
margin: 0 0 6px 0; line-height: 1.3;
|
|
1851
|
-
}
|
|
1852
|
-
.meta-panel h1.untitled { color: var(--fg-40); font-style: italic; font-weight: 400; }
|
|
1853
|
-
.meta-panel .cwd {
|
|
1854
|
-
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
1855
|
-
margin-bottom: 12px; word-break: break-all;
|
|
1856
|
-
}
|
|
1857
|
-
.meta-panel .stats {
|
|
1858
|
-
display: flex; gap: 22px; font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
1859
|
-
flex-wrap: wrap;
|
|
1860
|
-
}
|
|
1861
|
-
.meta-panel .stats .stat { display: flex; gap: 6px; }
|
|
1862
|
-
.meta-panel .stats .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
|
1863
|
-
.meta-panel .stats .stat .val { color: var(--fg); font-weight: 600; }
|
|
1864
|
-
|
|
1865
|
-
.section-label {
|
|
1866
|
-
padding: 8px 4px;
|
|
1867
|
-
font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
|
|
1868
|
-
color: var(--fg-40); text-transform: uppercase;
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
/* ── Task entries ───────────────────────────────────────────────────── */
|
|
1872
|
-
.task {
|
|
1873
|
-
border: 1px solid var(--border); background: rgba(255,255,255,0.015);
|
|
1874
|
-
border-radius: 8px; padding: 14px 16px; margin-bottom: 10px;
|
|
1875
|
-
}
|
|
1876
|
-
.task .row1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
1877
|
-
.task .agent {
|
|
1878
|
-
font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
|
1879
|
-
background: rgba(120, 180, 255, 0.10); color: #9bbfff; border: 1px solid rgba(120, 180, 255, 0.18);
|
|
1880
|
-
}
|
|
1881
|
-
.task .status {
|
|
1882
|
-
font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
|
1883
|
-
text-transform: uppercase; letter-spacing: 0.05em;
|
|
1884
|
-
}
|
|
1885
|
-
/* Pattern 8 ride-along revert: f830183 demoted .completed to muted neutral.
|
|
1886
|
-
Restoring green — actionable success is a load-bearing semantic signal
|
|
1887
|
-
in agentic UX. */
|
|
1888
|
-
.task .status.completed { background: rgba(40,200,64,0.10); color: var(--allow); border: 1px solid rgba(40,200,64,0.20); }
|
|
1889
|
-
.task .status.running { background: var(--accent-wash); color: var(--accent); border: 1px solid rgba(255,154,118,0.25); }
|
|
1890
|
-
.task .status.error, .task .status.failed { background: rgba(220, 60, 60, 0.10); color: var(--block); border: 1px solid rgba(220, 60, 60, 0.18); }
|
|
1891
|
-
.task .status.abandoned { background: rgba(255,255,255,0.04); color: var(--fg-40); border: 1px solid var(--border); }
|
|
1892
|
-
.task .ts { font-family: var(--mono); font-size: 11px; color: var(--fg-40); margin-left: auto; }
|
|
1893
|
-
.task .prompt { color: var(--fg); font-size: 13px; line-height: 1.5; word-wrap: break-word; }
|
|
1894
|
-
.task .duration { font-family: var(--mono); font-size: 11px; color: var(--fg-50); margin-top: 8px; }
|
|
1895
|
-
.task .duration b { color: var(--fg-70); font-weight: 600; }
|
|
1896
|
-
.task .steps { margin-top: 8px; padding-left: 12px; border-left: 2px solid var(--border); font-family: var(--mono); font-size: 11px; color: var(--fg-50); }
|
|
1897
|
-
.task .steps .step { padding: 2px 0; }
|
|
1898
|
-
.task .steps .step.done::before { content: '✓ '; color: var(--allow); }
|
|
1899
|
-
|
|
1900
|
-
/* Pattern 21 · Buffered reveal — cascade animation for completed steps */
|
|
1901
|
-
.task .steps.buffered-reveal .step {
|
|
1902
|
-
opacity: 0;
|
|
1903
|
-
transform: translateY(4px);
|
|
1904
|
-
animation: br-cascade 80ms ease-out forwards;
|
|
1905
|
-
}
|
|
1906
|
-
.task .steps.buffered-reveal .step:nth-child(1) { animation-delay: 0ms; }
|
|
1907
|
-
.task .steps.buffered-reveal .step:nth-child(2) { animation-delay: 80ms; }
|
|
1908
|
-
.task .steps.buffered-reveal .step:nth-child(3) { animation-delay: 160ms; }
|
|
1909
|
-
.task .steps.buffered-reveal .step:nth-child(4) { animation-delay: 240ms; }
|
|
1910
|
-
.task .steps.buffered-reveal .step:nth-child(5) { animation-delay: 320ms; }
|
|
1911
|
-
.task .steps.buffered-reveal .step:nth-child(6) { animation-delay: 400ms; }
|
|
1912
|
-
.task .steps.buffered-reveal .step:nth-child(7) { animation-delay: 480ms; }
|
|
1913
|
-
.task .steps.buffered-reveal .step:nth-child(8) { animation-delay: 560ms; }
|
|
1914
|
-
.task .steps.buffered-reveal .step:nth-child(9) { animation-delay: 640ms; }
|
|
1915
|
-
.task .steps.buffered-reveal .step:nth-child(10) { animation-delay: 720ms; }
|
|
1916
|
-
@keyframes br-cascade {
|
|
1917
|
-
to { opacity: 1; transform: translateY(0); }
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
.empty {
|
|
1921
|
-
text-align: center; padding: 60px 24px; color: var(--fg-50); font-family: var(--mono); font-size: 13px;
|
|
1922
|
-
}
|
|
1923
|
-
.empty .pill {
|
|
1924
|
-
display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: 16px;
|
|
1925
|
-
margin-bottom: 12px; font-size: 11px;
|
|
1926
|
-
}
|
|
1927
|
-
.empty .hint { font-size: 12px; color: var(--fg-40); }
|
|
1928
|
-
.empty a { color: var(--accent); }
|
|
1929
|
-
|
|
1930
|
-
/* ── Reply bar at thread bottom (Slack-shape) ───────────────────────── */
|
|
1931
|
-
.reply-bar {
|
|
1932
|
-
margin-top: 16px;
|
|
1933
|
-
border: 1px solid var(--border); background: rgba(255,255,255,0.02);
|
|
1934
|
-
border-radius: 10px; padding: 12px 14px;
|
|
1935
|
-
}
|
|
1936
|
-
.reply-bar.disabled { opacity: 0.6; }
|
|
1937
|
-
.reply-bar .reply-label {
|
|
1938
|
-
font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.16em;
|
|
1939
|
-
color: var(--fg-40); text-transform: uppercase; margin-bottom: 8px;
|
|
1940
|
-
}
|
|
1941
|
-
.reply-bar textarea {
|
|
1942
|
-
width: 100%; min-height: 60px; padding: 10px 12px;
|
|
1943
|
-
background: var(--bg-2); color: var(--fg); border: 1px solid var(--border);
|
|
1944
|
-
border-radius: 6px; font-family: var(--sans); font-size: 13px; line-height: 1.5;
|
|
1945
|
-
resize: vertical; outline: none;
|
|
1946
|
-
}
|
|
1947
|
-
.reply-bar textarea:focus { border-color: var(--accent); }
|
|
1948
|
-
.reply-bar textarea::placeholder { color: var(--fg-30); }
|
|
1949
|
-
.reply-bar .row {
|
|
1950
|
-
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
1951
|
-
margin-top: 8px;
|
|
1952
|
-
}
|
|
1953
|
-
.reply-bar .hint { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
|
|
1954
|
-
.reply-bar button {
|
|
1955
|
-
background: rgba(255,154,118,0.10); color: var(--accent); border: 1px solid rgba(255,154,118,0.3);
|
|
1956
|
-
padding: 6px 16px; border-radius: 6px; font-family: var(--mono); font-size: 12px; cursor: pointer;
|
|
1957
|
-
}
|
|
1958
|
-
.reply-bar button:hover { background: rgba(255,154,118,0.18); }
|
|
1959
|
-
.reply-bar button:disabled { opacity: 0.4; cursor: default; }
|
|
1960
|
-
.reply-bar .status { font-family: var(--mono); font-size: 11px; color: var(--fg-50); margin-top: 8px; }
|
|
1961
|
-
.reply-bar .status.error { color: var(--block); }
|
|
1962
|
-
|
|
1963
|
-
code { font-family: var(--mono); background: rgba(255,255,255,0.05); padding: 1px 6px; border-radius: 4px; }
|
|
1964
|
-
</style>
|
|
1965
|
-
</head>
|
|
1966
|
-
<body>
|
|
1967
|
-
<nav class="floating-nav">
|
|
1968
|
-
<a href="/" class="logo">codehere</a>
|
|
1969
|
-
<a href="/" class="nav-link">Tasks</a>
|
|
1970
|
-
<a href="/threads" class="nav-link active">Threads</a>
|
|
1971
|
-
<a href="/history" class="nav-link">History</a>
|
|
1972
|
-
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
1973
|
-
<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>
|
|
1974
|
-
</nav>
|
|
1975
|
-
|
|
1976
|
-
<div class="shell slack-shape">
|
|
1977
|
-
|
|
1978
|
-
<aside class="channels-pane">
|
|
1979
|
-
<div class="pane-head"><span>Projects</span><span class="pane-count" id="projects-count">0</span></div>
|
|
1980
|
-
<div id="projects-list"><div class="pane-empty">Loading…</div></div>
|
|
1981
|
-
</aside>
|
|
1982
|
-
|
|
1983
|
-
<section class="thread-pane">
|
|
1984
|
-
<div id="thread-container"><div class="empty"><div class="pill">No project selected</div></div></div>
|
|
1985
|
-
|
|
1986
|
-
<!-- v0.6.0 Slice 4 — single reply input at thread bottom (Slack-shape).
|
|
1987
|
-
Routes to the LAST task's session in the thread via POST /api/task/<lastSessionId>/message. -->
|
|
1988
|
-
<div class="reply-bar disabled" id="reply-bar" style="display:none">
|
|
1989
|
-
<div class="reply-label">Reply to thread</div>
|
|
1990
|
-
<textarea id="reply-text" placeholder="Reply to this thread… (Cmd+Enter / Ctrl+Enter to send)" rows="2" disabled></textarea>
|
|
1991
|
-
<div class="row">
|
|
1992
|
-
<span class="hint" id="reply-hint">No active session — spawn a task first</span>
|
|
1993
|
-
<button type="button" id="reply-send" disabled>Send</button>
|
|
1994
|
-
</div>
|
|
1995
|
-
<div class="status" id="reply-status" style="display:none"></div>
|
|
1996
|
-
</div>
|
|
1997
|
-
</section>
|
|
1998
|
-
|
|
1999
|
-
<aside class="members-pane">
|
|
2000
|
-
<div class="pane-head"><span>Agents</span><span class="pane-count" id="agents-count">0</span></div>
|
|
2001
|
-
<div id="agents-list"><div class="pane-empty">Loading…</div></div>
|
|
2002
|
-
</aside>
|
|
2003
|
-
|
|
2004
|
-
</div>
|
|
2005
|
-
|
|
2006
|
-
<script>
|
|
2007
|
-
(function () {
|
|
2008
|
-
const projectsList = document.getElementById('projects-list');
|
|
2009
|
-
const projectsCount = document.getElementById('projects-count');
|
|
2010
|
-
const agentsList = document.getElementById('agents-list');
|
|
2011
|
-
const agentsCount = document.getElementById('agents-count');
|
|
2012
|
-
const container = document.getElementById('thread-container');
|
|
2013
|
-
const replyBar = document.getElementById('reply-bar');
|
|
2014
|
-
const replyText = document.getElementById('reply-text');
|
|
2015
|
-
const replySend = document.getElementById('reply-send');
|
|
2016
|
-
const replyHint = document.getElementById('reply-hint');
|
|
2017
|
-
const replyStatus = document.getElementById('reply-status');
|
|
2018
|
-
|
|
2019
|
-
let currentSlug = null;
|
|
2020
|
-
let lastSessionId = null;
|
|
2021
|
-
|
|
2022
|
-
function escapeHtml(s) {
|
|
2023
|
-
return String(s == null ? '' : s)
|
|
2024
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2025
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
2026
|
-
}
|
|
2027
|
-
// Per-agent badge helpers shared with TASK_HTML / HISTORY_HTML — single
|
|
2028
|
-
// source of truth in AGENT_BADGE_JS at the top of this module.
|
|
2029
|
-
${AGENT_BADGE_JS}
|
|
2030
|
-
function timeAgo(ts) {
|
|
2031
|
-
if (!ts) return '';
|
|
2032
|
-
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
2033
|
-
if (s < 60) return s + 's ago';
|
|
2034
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
2035
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
2036
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
2037
|
-
}
|
|
2038
|
-
function fmtTime(ts) {
|
|
2039
|
-
if (!ts) return '';
|
|
2040
|
-
const d = new Date(ts);
|
|
2041
|
-
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
2042
|
-
}
|
|
2043
|
-
function fmtDuration(ms) {
|
|
2044
|
-
if (ms == null) return '';
|
|
2045
|
-
if (ms < 1000) return ms + 'ms';
|
|
2046
|
-
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
2047
|
-
if (ms < 3600000) return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
2048
|
-
if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
|
|
2049
|
-
// Days-scale: this is almost always a stale-running session that
|
|
2050
|
-
// got reconciled days later. Show "Nd" — actual elapsed runtime is
|
|
2051
|
-
// unknowable so don't pretend "108h 6m" carries meaning.
|
|
2052
|
-
return Math.floor(ms / 86400000) + 'd';
|
|
2053
|
-
}
|
|
2054
|
-
function shortPath(p) {
|
|
2055
|
-
if (!p) return '~';
|
|
2056
|
-
const parts = p.split('/');
|
|
2057
|
-
return parts.length <= 3 ? p : '…/' + parts.slice(-2).join('/');
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
function setReplyState(sessionId, agent) {
|
|
2061
|
-
lastSessionId = sessionId;
|
|
2062
|
-
if (sessionId) {
|
|
2063
|
-
replyBar.style.display = 'block';
|
|
2064
|
-
replyBar.classList.remove('disabled');
|
|
2065
|
-
replyText.disabled = false;
|
|
2066
|
-
replySend.disabled = false;
|
|
2067
|
-
replyHint.textContent = 'Replying to last task (' + (agent || 'unknown') + ')';
|
|
2068
|
-
} else {
|
|
2069
|
-
replyBar.style.display = 'block';
|
|
2070
|
-
replyBar.classList.add('disabled');
|
|
2071
|
-
replyText.disabled = true;
|
|
2072
|
-
replySend.disabled = true;
|
|
2073
|
-
replyHint.textContent = 'No active session — spawn a task first';
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
async function sendReply() {
|
|
2078
|
-
const text = (replyText.value || '').trim();
|
|
2079
|
-
if (!text || !lastSessionId) return;
|
|
2080
|
-
replySend.disabled = true;
|
|
2081
|
-
replySend.textContent = '…';
|
|
2082
|
-
replyStatus.style.display = 'none';
|
|
2083
|
-
replyStatus.classList.remove('error');
|
|
2084
|
-
try {
|
|
2085
|
-
const r = await fetch('/api/task/' + encodeURIComponent(lastSessionId) + '/message', {
|
|
2086
|
-
method: 'POST',
|
|
2087
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2088
|
-
body: JSON.stringify({ text }),
|
|
2089
|
-
});
|
|
2090
|
-
if (!r.ok) {
|
|
2091
|
-
const data = await r.json().catch(() => ({}));
|
|
2092
|
-
throw new Error(data.error || ('HTTP ' + r.status));
|
|
2093
|
-
}
|
|
2094
|
-
const data = await r.json();
|
|
2095
|
-
replyText.value = '';
|
|
2096
|
-
replyStatus.style.display = 'block';
|
|
2097
|
-
replyStatus.textContent = 'Reply sent in ' + fmtDuration(data.durationMs) + ' — refreshing thread…';
|
|
2098
|
-
if (currentSlug) loadThread(currentSlug);
|
|
2099
|
-
} catch (err) {
|
|
2100
|
-
replyStatus.style.display = 'block';
|
|
2101
|
-
replyStatus.classList.add('error');
|
|
2102
|
-
replyStatus.textContent = 'Send failed: ' + (err && err.message ? err.message : err);
|
|
2103
|
-
} finally {
|
|
2104
|
-
replySend.disabled = false;
|
|
2105
|
-
replySend.textContent = 'Send';
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
replySend.addEventListener('click', sendReply);
|
|
2110
|
-
replyText.addEventListener('keydown', (e) => {
|
|
2111
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); sendReply(); }
|
|
2112
|
-
});
|
|
2113
|
-
|
|
2114
|
-
function renderThread(thread) {
|
|
2115
|
-
if (!thread) {
|
|
2116
|
-
container.innerHTML = '<div class="empty"><div class="pill">Project not found</div></div>';
|
|
2117
|
-
setReplyState(null, null);
|
|
2118
|
-
return;
|
|
2119
|
-
}
|
|
2120
|
-
const titleHtml = thread.title
|
|
2121
|
-
? '<h1>' + escapeHtml(thread.title) + '</h1>'
|
|
2122
|
-
: '<h1 class="untitled">(untitled — Title agent will name this on first new spawn)</h1>';
|
|
2123
|
-
const cwdHtml = thread.cwd ? '<div class="cwd">' + escapeHtml(thread.cwd) + '</div>' : '';
|
|
2124
|
-
const lastActivity = thread.lastActivityAt ? fmtTime(thread.lastActivityAt) + ' · ' + timeAgo(thread.lastActivityAt) : '—';
|
|
2125
|
-
|
|
2126
|
-
let html = '<div class="meta-panel">' +
|
|
2127
|
-
titleHtml +
|
|
2128
|
-
cwdHtml +
|
|
2129
|
-
'<div class="stats">' +
|
|
2130
|
-
'<div class="stat"><span class="label">Tasks</span> <span class="val">' + thread.taskCount + '</span></div>' +
|
|
2131
|
-
'<div class="stat"><span class="label">Last activity</span> <span class="val">' + escapeHtml(lastActivity) + '</span></div>' +
|
|
2132
|
-
'<div class="stat"><span class="label">Slug</span> <span class="val">' + escapeHtml(thread.slug) + '</span></div>' +
|
|
2133
|
-
'</div>' +
|
|
2134
|
-
'</div>';
|
|
2135
|
-
|
|
2136
|
-
if (!thread.tasks || thread.tasks.length === 0) {
|
|
2137
|
-
html += '<div class="empty"><div class="pill">No tasks yet</div><div class="hint">Spawn one from the <a href="/">Tasks</a> page.</div></div>';
|
|
2138
|
-
container.innerHTML = html;
|
|
2139
|
-
setReplyState(null, null);
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
// v0.6 — render-cap to keep huge projects (685+ turns on the founder's
|
|
2144
|
-
// dev repo) from blowing the page to ~80,000px tall. "Show all" toggle
|
|
2145
|
-
// mirrors the dashboard 5-card cap pattern. Chronological order
|
|
2146
|
-
// preserved (Slack-shape: oldest first, recent at bottom).
|
|
2147
|
-
const RENDER_CAP = 25;
|
|
2148
|
-
const totalTurns = thread.tasks.length;
|
|
2149
|
-
const showAll = container.dataset.showAllTurns === 'true';
|
|
2150
|
-
const renderedTurns = showAll ? thread.tasks : thread.tasks.slice(-RENDER_CAP);
|
|
2151
|
-
const isCapped = !showAll && totalTurns > RENDER_CAP;
|
|
2152
|
-
|
|
2153
|
-
if (isCapped) {
|
|
2154
|
-
html += '<div class="section-label" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
|
|
2155
|
-
'<span>Thread · showing <b>' + renderedTurns.length + '</b> most recent of ' + totalTurns + ' turns</span>' +
|
|
2156
|
-
'<button type="button" class="show-all-turns" style="font-size:10.5px;padding:3px 8px;border-radius:4px;border:1px solid var(--border-soft);background:transparent;color:var(--fg-70);cursor:pointer;font-family:inherit">Show all ' + totalTurns + '</button>' +
|
|
2157
|
-
'</div>';
|
|
2158
|
-
} else {
|
|
2159
|
-
html += '<div class="section-label">Thread · ' + totalTurns + ' turn' + (totalTurns === 1 ? '' : 's') + '</div>';
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
for (const t of renderedTurns) {
|
|
2163
|
-
const status = (t.status || '').toLowerCase();
|
|
2164
|
-
// v0.6 — cap per-task steps at last 5 (mirrors dashboard renderSteps
|
|
2165
|
-
// tail-3 pattern). A long task with 100+ steps × 25 turns rendered
|
|
2166
|
-
// = 2500 step DOM nodes. Show last 5 + "+N more" pill.
|
|
2167
|
-
let stepsHtml = '';
|
|
2168
|
-
if (t.steps && t.steps.length) {
|
|
2169
|
-
const STEP_TAIL = 5;
|
|
2170
|
-
const stepTail = t.steps.slice(-STEP_TAIL);
|
|
2171
|
-
const stepHidden = t.steps.length - stepTail.length;
|
|
2172
|
-
stepsHtml = '<div class="steps">' +
|
|
2173
|
-
stepTail.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') +
|
|
2174
|
-
(stepHidden > 0 ? '<div class="step muted">+' + stepHidden + ' earlier step' + (stepHidden === 1 ? '' : 's') + '</div>' : '') +
|
|
2175
|
-
'</div>';
|
|
2176
|
-
}
|
|
2177
|
-
const durHtml = (t.durationMs != null)
|
|
2178
|
-
? '<div class="duration">Duration: <b>' + fmtDuration(t.durationMs) + '</b></div>'
|
|
2179
|
-
: (status === 'running' ? '<div class="duration">Running…</div>' : '');
|
|
2180
|
-
|
|
2181
|
-
html += '<div class="task">' +
|
|
2182
|
-
'<div class="row1">' +
|
|
2183
|
-
agentBadgeHTML(t.agent || 'unknown') +
|
|
2184
|
-
' <span class="agent-name">' + escapeHtml(agentLabel(t.agent || 'unknown')) + '</span>' +
|
|
2185
|
-
'<span class="status ' + escapeHtml(status) + '">' + escapeHtml(status || 'unknown') + '</span>' +
|
|
2186
|
-
'<span class="ts">' + escapeHtml(fmtTime(t.startedAt)) + ' · ' + escapeHtml(timeAgo(t.startedAt)) + '</span>' +
|
|
2187
|
-
'</div>' +
|
|
2188
|
-
'<div class="prompt">' + escapeHtml(t.prompt || '(no prompt)') + '</div>' +
|
|
2189
|
-
durHtml +
|
|
2190
|
-
stepsHtml +
|
|
2191
|
-
'</div>';
|
|
2192
|
-
}
|
|
2193
|
-
container.innerHTML = html;
|
|
2194
|
-
// Wire the "Show all N turns" toggle (rendered only when capped). Sets
|
|
2195
|
-
// the dataset flag and re-runs renderThread with the same data so the
|
|
2196
|
-
// cap branch falls through.
|
|
2197
|
-
const showAllBtn = container.querySelector('.show-all-turns');
|
|
2198
|
-
if (showAllBtn) {
|
|
2199
|
-
showAllBtn.addEventListener('click', () => {
|
|
2200
|
-
container.dataset.showAllTurns = 'true';
|
|
2201
|
-
renderThread(thread);
|
|
2202
|
-
});
|
|
2203
|
-
}
|
|
2204
|
-
const lastTask = thread.tasks[thread.tasks.length - 1];
|
|
2205
|
-
setReplyState(lastTask.sessionId, lastTask.agent);
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
function renderProjects(list) {
|
|
2209
|
-
projectsCount.textContent = String(list.length);
|
|
2210
|
-
if (list.length === 0) {
|
|
2211
|
-
projectsList.innerHTML = '<div class="pane-empty">No projects yet</div>';
|
|
2212
|
-
return;
|
|
2213
|
-
}
|
|
2214
|
-
projectsList.innerHTML = list.map(p => {
|
|
2215
|
-
const active = p.slug === currentSlug ? ' active' : '';
|
|
2216
|
-
const name = escapeHtml(p.title || '(untitled)');
|
|
2217
|
-
const cwdHint = escapeHtml(shortPath(p.cwd));
|
|
2218
|
-
// Pencil affordance — visible on hover via CSS .channel:hover .channel-rename.
|
|
2219
|
-
// Triggers prompt() for v1 simplicity; inline input UX is a future iteration.
|
|
2220
|
-
return '<div class="channel' + active + '" data-slug="' + escapeHtml(p.slug) + '">' +
|
|
2221
|
-
'<span class="channel-glyph">#</span>' +
|
|
2222
|
-
'<div style="flex:1;min-width:0">' +
|
|
2223
|
-
'<div class="channel-name">' + name + '</div>' +
|
|
2224
|
-
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
2225
|
-
'</div>' +
|
|
2226
|
-
'<button class="channel-rename" data-slug="' + escapeHtml(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>' +
|
|
2227
|
-
'</div>';
|
|
2228
|
-
}).join('');
|
|
2229
|
-
projectsList.querySelectorAll('.channel').forEach(el => {
|
|
2230
|
-
el.addEventListener('click', (ev) => {
|
|
2231
|
-
// Skip channel-switch when the click was the rename button — that has its own handler.
|
|
2232
|
-
if (ev.target && (ev.target).classList && (ev.target).classList.contains('channel-rename')) return;
|
|
2233
|
-
const slug = el.getAttribute('data-slug');
|
|
2234
|
-
if (!slug || slug === currentSlug) return;
|
|
2235
|
-
currentSlug = slug;
|
|
2236
|
-
renderProjects(list); // re-highlight active
|
|
2237
|
-
loadThread(slug);
|
|
2238
|
-
const url = new URL(window.location.href);
|
|
2239
|
-
url.searchParams.set('project', slug);
|
|
2240
|
-
history.replaceState(null, '', url.toString());
|
|
2241
|
-
});
|
|
2242
|
-
});
|
|
2243
|
-
projectsList.querySelectorAll('.channel-rename').forEach(btn => {
|
|
2244
|
-
btn.addEventListener('click', (ev) => {
|
|
2245
|
-
ev.stopPropagation(); // don't trigger channel-switch
|
|
2246
|
-
const slug = btn.getAttribute('data-slug');
|
|
2247
|
-
if (!slug) return;
|
|
2248
|
-
const channel = btn.closest('.channel');
|
|
2249
|
-
const project = list.find(p => p.slug === slug);
|
|
2250
|
-
const current = (project && project.title) || '';
|
|
2251
|
-
startInlineRename(channel, slug, current, () => {
|
|
2252
|
-
loadProjects();
|
|
2253
|
-
// Renamed project may be the visible one — refresh the main pane
|
|
2254
|
-
// header immediately instead of waiting for the 5s poll. Without
|
|
2255
|
-
// this, the sidebar updates but the open thread keeps the old title.
|
|
2256
|
-
if (slug === currentSlug) loadThread(slug);
|
|
2257
|
-
});
|
|
2258
|
-
});
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
2262
|
-
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
2263
|
-
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
2264
|
-
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
2265
|
-
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
2266
|
-
if (!channelEl) return;
|
|
2267
|
-
const nameEl = channelEl.querySelector('.channel-name');
|
|
2268
|
-
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
2269
|
-
nameEl.dataset.editing = '1';
|
|
2270
|
-
const originalText = nameEl.textContent;
|
|
2271
|
-
const input = document.createElement('input');
|
|
2272
|
-
input.type = 'text';
|
|
2273
|
-
input.value = currentTitle;
|
|
2274
|
-
input.maxLength = 100;
|
|
2275
|
-
input.className = 'channel-rename-input';
|
|
2276
|
-
nameEl.textContent = '';
|
|
2277
|
-
nameEl.appendChild(input);
|
|
2278
|
-
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
2279
|
-
let cleanedUp = false;
|
|
2280
|
-
function cleanup(restoreText) {
|
|
2281
|
-
if (cleanedUp) return;
|
|
2282
|
-
cleanedUp = true;
|
|
2283
|
-
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
2284
|
-
nameEl.textContent = restoreText;
|
|
2285
|
-
delete nameEl.dataset.editing;
|
|
2286
|
-
}
|
|
2287
|
-
function commit() {
|
|
2288
|
-
if (cleanedUp) return;
|
|
2289
|
-
const trimmed = input.value.trim();
|
|
2290
|
-
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
2291
|
-
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
2292
|
-
method: 'POST',
|
|
2293
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2294
|
-
body: JSON.stringify({ title: trimmed }),
|
|
2295
|
-
})
|
|
2296
|
-
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
2297
|
-
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
2298
|
-
.catch(() => {
|
|
2299
|
-
input.classList.remove('error');
|
|
2300
|
-
void input.offsetWidth;
|
|
2301
|
-
input.classList.add('error');
|
|
2302
|
-
});
|
|
2303
|
-
}
|
|
2304
|
-
input.addEventListener('keydown', (ev) => {
|
|
2305
|
-
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
2306
|
-
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
2307
|
-
});
|
|
2308
|
-
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
function presenceClass(state) {
|
|
2312
|
-
if (state === 'ready') return 'ready';
|
|
2313
|
-
if (state === 'needs_install' || state === 'needs_auth' || state === 'billed_key_required') return 'warn';
|
|
2314
|
-
return 'offline';
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
function renderAgents(adapters) {
|
|
2318
|
-
agentsCount.textContent = String(adapters.length);
|
|
2319
|
-
agentsList.innerHTML = adapters.map(a => {
|
|
2320
|
-
const cls = presenceClass(a.state);
|
|
2321
|
-
return '<div class="member">' +
|
|
2322
|
-
'<span class="member-presence ' + cls + '">●</span>' +
|
|
2323
|
-
'<span class="member-label">' + escapeHtml(a.label || a.id) + '</span>' +
|
|
2324
|
-
'</div>';
|
|
2325
|
-
}).join('');
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
function loadThread(slug) {
|
|
2329
|
-
if (!slug) { container.innerHTML = '<div class="empty"><div class="pill">No project selected</div></div>'; setReplyState(null, null); return; }
|
|
2330
|
-
// Reset the show-all-turns flag on every project switch so a user
|
|
2331
|
-
// moving from a 686-turn project to a 5-turn one doesn't carry the
|
|
2332
|
-
// toggled state across.
|
|
2333
|
-
delete container.dataset.showAllTurns;
|
|
2334
|
-
fetch('/api/projects/' + encodeURIComponent(slug))
|
|
2335
|
-
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
2336
|
-
.then(data => renderThread(data.thread))
|
|
2337
|
-
.catch(err => { container.innerHTML = '<div class="empty"><div class="pill">Load error: ' + escapeHtml(String(err)) + '</div></div>'; });
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
function loadProjects() {
|
|
2341
|
-
fetch('/api/projects')
|
|
2342
|
-
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
2343
|
-
.then(data => {
|
|
2344
|
-
const list = data.projects || [];
|
|
2345
|
-
renderProjects(list);
|
|
2346
|
-
if (list.length > 0 && !currentSlug) {
|
|
2347
|
-
const urlSlug = new URLSearchParams(window.location.search).get('project');
|
|
2348
|
-
const slugToUse = (urlSlug && list.some(p => p.slug === urlSlug)) ? urlSlug : list[0].slug;
|
|
2349
|
-
currentSlug = slugToUse;
|
|
2350
|
-
renderProjects(list);
|
|
2351
|
-
loadThread(slugToUse);
|
|
2352
|
-
} else if (list.length === 0) {
|
|
2353
|
-
container.innerHTML = '<div class="empty"><div class="pill">No projects yet</div><div class="hint">Spawn a task from the <a href="/">Tasks</a> page — it will appear here as a project thread.</div></div>';
|
|
2354
|
-
setReplyState(null, null);
|
|
2355
|
-
}
|
|
2356
|
-
})
|
|
2357
|
-
.catch(() => {});
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
function loadAgents() {
|
|
2361
|
-
fetch('/api/readiness/agents')
|
|
2362
|
-
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
2363
|
-
.then(data => renderAgents(data.agents || []))
|
|
2364
|
-
.catch(() => {});
|
|
2365
|
-
}
|
|
2366
|
-
|
|
2367
|
-
loadProjects();
|
|
2368
|
-
loadAgents();
|
|
2369
|
-
setInterval(() => {
|
|
2370
|
-
if (currentSlug) loadThread(currentSlug);
|
|
2371
|
-
if (Math.random() < 0.25) { loadProjects(); loadAgents(); }
|
|
2372
|
-
}, 5000);
|
|
2373
|
-
})();
|
|
2374
|
-
</script>
|
|
2375
|
-
</body>
|
|
2376
|
-
</html>`;
|
|
2377
|
-
//# sourceMappingURL=ui-page.js.map
|