codehere 0.5.0-alpha.6 → 0.6.0-alpha.1
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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +75 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +95 -9
- package/dist/infrastructure/protocols/mcp-server-impl.js +533 -14
- package/dist/infrastructure/protocols/ui-page.js +2598 -212
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +346 -24
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ~/code/codehere-landing/app/globals.css (not guessed): HSL tokens matching
|
|
10
10
|
* the marketing site, keyframes (shimmer, badge-glow, beam-pulse, fade-up),
|
|
11
11
|
* and utility classes (.glass-card, .gradient-text-shimmer, .glow-line,
|
|
12
|
-
* .hero-beam
|
|
12
|
+
* .hero-beam).
|
|
13
13
|
*/
|
|
14
14
|
const SHARED_STYLES = `
|
|
15
15
|
:root {
|
|
@@ -31,16 +31,27 @@ const SHARED_STYLES = `
|
|
|
31
31
|
--allow: #28c840;
|
|
32
32
|
--block: #ff5f57;
|
|
33
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);
|
|
34
45
|
--radius: 8px;
|
|
35
46
|
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
36
47
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
|
-
/* Keyframes
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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). */
|
|
44
55
|
@keyframes badge-glow {
|
|
45
56
|
0%, 100% { box-shadow: 0 0 0 rgba(40,200,64,0); }
|
|
46
57
|
50% { box-shadow: 0 0 10px rgba(40,200,64,0.5); }
|
|
@@ -49,24 +60,6 @@ const SHARED_STYLES = `
|
|
|
49
60
|
from { opacity: 0; transform: translateY(12px); filter: blur(2px); }
|
|
50
61
|
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
|
51
62
|
}
|
|
52
|
-
@keyframes beam-pulse {
|
|
53
|
-
0%, 100% { opacity: 0.35; transform: translateX(-50%) scale(1); }
|
|
54
|
-
50% { opacity: 0.55; transform: translateX(-50%) scale(1.04); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/* Text gradient (landing's hero motif) */
|
|
58
|
-
.gradient-text-shimmer {
|
|
59
|
-
background: linear-gradient(
|
|
60
|
-
90deg,
|
|
61
|
-
#ff9a76 0%, #f5c6b0 20%, #ff9a76 35%,
|
|
62
|
-
#4a90e2 50%, #8ab8ee 65%, #4a90e2 80%, #ff9a76 100%
|
|
63
|
-
);
|
|
64
|
-
background-size: 200% auto;
|
|
65
|
-
-webkit-background-clip: text;
|
|
66
|
-
background-clip: text;
|
|
67
|
-
-webkit-text-fill-color: transparent;
|
|
68
|
-
animation: shimmer 6s linear infinite;
|
|
69
|
-
}
|
|
70
63
|
|
|
71
64
|
/* Glass card — landing's .glass-card with hover lift */
|
|
72
65
|
.glass-card {
|
|
@@ -89,43 +82,224 @@ const SHARED_STYLES = `
|
|
|
89
82
|
margin: 18px 0;
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
/* Per-
|
|
93
|
-
|
|
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
94
|
display: inline-flex;
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
122
|
vertical-align: middle;
|
|
98
123
|
}
|
|
99
|
-
.
|
|
100
|
-
|
|
101
|
-
|
|
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;
|
|
102
236
|
}
|
|
103
|
-
.card-lights .r { background: var(--block); }
|
|
104
|
-
.card-lights .y { background: var(--warn); }
|
|
105
|
-
.card-lights .g { background: var(--allow); }
|
|
106
237
|
|
|
107
|
-
/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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); }
|
|
129
303
|
}
|
|
130
304
|
|
|
131
305
|
/* Running-state pill animation */
|
|
@@ -202,6 +376,9 @@ const SHARED_STYLES = `
|
|
|
202
376
|
.status-banner .stat { display: inline-flex; align-items: center; gap: 6px; }
|
|
203
377
|
.status-banner .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
|
204
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. */
|
|
205
382
|
.status-banner .stat .value.live { color: var(--allow); }
|
|
206
383
|
.status-banner .stat.blocked .value.nonzero { color: var(--block); }
|
|
207
384
|
.status-banner .sep { color: var(--fg-20); user-select: none; }
|
|
@@ -285,21 +462,38 @@ const SHARED_STYLES = `
|
|
|
285
462
|
gap: 12px;
|
|
286
463
|
color: var(--fg-70);
|
|
287
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; }
|
|
288
473
|
.final-report .fr-file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
289
474
|
.final-report .fr-file .lines { color: var(--fg-50); white-space: nowrap; }
|
|
290
475
|
.final-report .fr-file .lines .add { color: var(--allow); }
|
|
291
476
|
.final-report .fr-file .lines .rem { color: var(--block); }
|
|
292
|
-
|
|
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 {
|
|
293
486
|
display: inline-block;
|
|
294
487
|
padding: 1px 7px; border-radius: 999px;
|
|
295
488
|
font-size: 10px; font-weight: 600;
|
|
296
489
|
border: 1px solid transparent;
|
|
297
490
|
min-width: 28px; text-align: center;
|
|
491
|
+
font-family: var(--mono);
|
|
298
492
|
}
|
|
299
|
-
.
|
|
300
|
-
.
|
|
301
|
-
.
|
|
302
|
-
.
|
|
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); }
|
|
303
497
|
.final-report .fr-tests, .final-report .fr-none {
|
|
304
498
|
margin-top: 8px; padding-top: 8px;
|
|
305
499
|
border-top: 1px solid var(--border-soft);
|
|
@@ -333,6 +527,427 @@ const SHARED_STYLES = `
|
|
|
333
527
|
}
|
|
334
528
|
.floating-nav a.nav-link:hover { color: var(--fg); }
|
|
335
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
|
+
}
|
|
336
951
|
`;
|
|
337
952
|
// ── /history — audit feed (what we shipped earlier in this session) ────────────
|
|
338
953
|
export const HISTORY_HTML = `<!doctype html>
|
|
@@ -355,7 +970,7 @@ ${SHARED_STYLES}
|
|
|
355
970
|
-webkit-font-smoothing: antialiased;
|
|
356
971
|
}
|
|
357
972
|
|
|
358
|
-
/* ── Header
|
|
973
|
+
/* ── Header ─────────────────────────────────────────────────────────── */
|
|
359
974
|
header {
|
|
360
975
|
position: sticky; top: 0; z-index: 10;
|
|
361
976
|
background: rgba(0,0,0,0.75);
|
|
@@ -366,15 +981,6 @@ ${SHARED_STYLES}
|
|
|
366
981
|
display: flex; align-items: center; justify-content: space-between;
|
|
367
982
|
}
|
|
368
983
|
header .brand { display: flex; align-items: center; gap: 14px; }
|
|
369
|
-
header .lights { display: inline-flex; gap: 6px; }
|
|
370
|
-
header .lights span {
|
|
371
|
-
width: 11px; height: 11px; border-radius: 50%;
|
|
372
|
-
background: var(--bg-2);
|
|
373
|
-
border: 1px solid var(--border);
|
|
374
|
-
}
|
|
375
|
-
header .lights .r { background: var(--block); border-color: transparent; }
|
|
376
|
-
header .lights .y { background: var(--warn); border-color: transparent; }
|
|
377
|
-
header .lights .g { background: var(--allow); border-color: transparent; }
|
|
378
984
|
header .title {
|
|
379
985
|
font-family: var(--mono);
|
|
380
986
|
font-size: 13px; font-weight: 600;
|
|
@@ -390,7 +996,7 @@ ${SHARED_STYLES}
|
|
|
390
996
|
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
|
391
997
|
background: var(--fg-30); margin-right: 6px; vertical-align: middle;
|
|
392
998
|
}
|
|
393
|
-
header .meta .dot.live { background: var(--
|
|
999
|
+
header .meta .dot.live { background: var(--accent); }
|
|
394
1000
|
header .eyebrow {
|
|
395
1001
|
display: inline-block;
|
|
396
1002
|
font-family: var(--mono);
|
|
@@ -436,24 +1042,110 @@ ${SHARED_STYLES}
|
|
|
436
1042
|
|
|
437
1043
|
/* ── Feed ──────────────────────────────────────────────────────────── */
|
|
438
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
|
+
|
|
439
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. */
|
|
440
1083
|
#feed li {
|
|
441
|
-
padding:
|
|
1084
|
+
padding: 10px 24px;
|
|
442
1085
|
border-bottom: 1px solid var(--border-soft);
|
|
443
|
-
display:
|
|
444
|
-
|
|
445
|
-
gap:
|
|
446
|
-
align-items: baseline;
|
|
1086
|
+
display: flex;
|
|
1087
|
+
align-items: center;
|
|
1088
|
+
gap: 12px;
|
|
447
1089
|
transition: background 120ms ease;
|
|
448
1090
|
}
|
|
449
1091
|
#feed li:hover { background: rgba(255,255,255,0.02); }
|
|
450
|
-
|
|
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
|
+
}
|
|
451
1140
|
#feed li .outcome {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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;
|
|
455
1146
|
text-align: center;
|
|
456
1147
|
border: 1px solid transparent;
|
|
1148
|
+
min-width: 56px;
|
|
457
1149
|
}
|
|
458
1150
|
#feed li .outcome.allow {
|
|
459
1151
|
color: var(--allow);
|
|
@@ -465,21 +1157,79 @@ ${SHARED_STYLES}
|
|
|
465
1157
|
background: rgba(255,95,87,0.10);
|
|
466
1158
|
border-color: rgba(255,95,87,0.30);
|
|
467
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
|
+
}
|
|
468
1165
|
#feed li .tool {
|
|
1166
|
+
order: 3; flex: 0 0 auto;
|
|
469
1167
|
color: var(--accent);
|
|
470
|
-
font-family: var(--mono); font-size:
|
|
1168
|
+
font-family: var(--mono); font-size: 11.5px;
|
|
1169
|
+
min-width: 60px;
|
|
471
1170
|
}
|
|
472
1171
|
#feed li .summary {
|
|
1172
|
+
order: 4; flex: 1 1 auto;
|
|
473
1173
|
color: var(--fg-70);
|
|
474
|
-
font-family: var(--mono); font-size:
|
|
1174
|
+
font-family: var(--mono); font-size: 11.5px;
|
|
475
1175
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1176
|
+
min-width: 0; /* allows ellipsis to engage in a flex child */
|
|
476
1177
|
}
|
|
477
1178
|
#feed li .score {
|
|
1179
|
+
order: 5; flex: 0 0 50px;
|
|
478
1180
|
text-align: right;
|
|
479
|
-
font-family: var(--mono); font-size:
|
|
1181
|
+
font-family: var(--mono); font-size: 10.5px;
|
|
480
1182
|
color: var(--warn);
|
|
481
1183
|
}
|
|
482
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; }
|
|
483
1233
|
|
|
484
1234
|
/* ── Empty state ───────────────────────────────────────────────────── */
|
|
485
1235
|
#empty {
|
|
@@ -515,6 +1265,8 @@ ${SHARED_STYLES}
|
|
|
515
1265
|
<a href="/" class="nav-link">Tasks</a>
|
|
516
1266
|
<a href="/threads" class="nav-link">Threads</a>
|
|
517
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>
|
|
518
1270
|
</nav>
|
|
519
1271
|
|
|
520
1272
|
<div class="tagline" style="margin-top:90px">
|
|
@@ -550,6 +1302,50 @@ ${SHARED_STYLES}
|
|
|
550
1302
|
|
|
551
1303
|
<div class="section-label">Audit feed · Live</div>
|
|
552
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>
|
|
553
1349
|
<ul id="feed"></ul>
|
|
554
1350
|
<div id="empty">
|
|
555
1351
|
<div class="pill">No tool calls yet</div>
|
|
@@ -560,13 +1356,19 @@ ${SHARED_STYLES}
|
|
|
560
1356
|
</main>
|
|
561
1357
|
|
|
562
1358
|
<script>
|
|
1359
|
+
${AGENT_BADGE_JS}
|
|
1360
|
+
|
|
563
1361
|
(function () {
|
|
564
1362
|
const feed = document.getElementById('feed');
|
|
565
1363
|
const empty = document.getElementById('empty');
|
|
566
|
-
|
|
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.
|
|
567
1370
|
const connEl = document.getElementById('conn');
|
|
568
1371
|
const dot = document.getElementById('live-dot');
|
|
569
|
-
let count = 0;
|
|
570
1372
|
|
|
571
1373
|
function shortPath(p) {
|
|
572
1374
|
const parts = String(p).split('/');
|
|
@@ -609,24 +1411,130 @@ ${SHARED_STYLES}
|
|
|
609
1411
|
function render(entry) {
|
|
610
1412
|
const ts = new Date(entry.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
611
1413
|
const blocked = Boolean(entry.blocked);
|
|
612
|
-
|
|
1414
|
+
// Bug A5 — derive a 3-way outcome (allow / block / override) and stamp
|
|
1415
|
+
// it as a data-attribute so the filter chips can hide non-matching rows
|
|
1416
|
+
// without re-rendering the feed.
|
|
1417
|
+
const isOverride = Boolean(entry.overrideReason);
|
|
1418
|
+
const outcome = isOverride ? 'override' : (blocked ? 'block' : 'allow');
|
|
613
1419
|
const tool = String(entry.toolName || entry.tool_name || '—');
|
|
614
1420
|
const score = entry.riskScore ?? entry.risk_score ?? 0;
|
|
615
1421
|
const summary = summarize(entry);
|
|
1422
|
+
// Cross-vendor audit-mirror (#110 slice 2-4) writes the originating
|
|
1423
|
+
// agent inside toolInput as JSON. Pull it for the per-row badge so
|
|
1424
|
+
// users can tell at a glance whether codex / aider / gemini / etc.
|
|
1425
|
+
// ran each call. Falls back to no-badge when toolInput isn't JSON
|
|
1426
|
+
// (legacy entries pre-mirror — Claude PreToolUse rows).
|
|
1427
|
+
let originAgent = '';
|
|
1428
|
+
try {
|
|
1429
|
+
const parsed = typeof entry.toolInput === 'string' ? JSON.parse(entry.toolInput) : entry.toolInput;
|
|
1430
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.agent === 'string') {
|
|
1431
|
+
originAgent = parsed.agent;
|
|
1432
|
+
}
|
|
1433
|
+
} catch { /* not JSON — leave blank */ }
|
|
1434
|
+
|
|
1435
|
+
// P12 follow-up — surface the chain seq number per row. Forensic
|
|
1436
|
+
// value: a reviewer can refer to a specific entry by its seq + the
|
|
1437
|
+
// hash banner up top makes it easy to verify integrity. Rows with
|
|
1438
|
+
// no seq (legacy entries) get a muted dash.
|
|
1439
|
+
const seq = (typeof entry.seq === 'number') ? entry.seq : null;
|
|
1440
|
+
const entryHash = (typeof entry.entryHash === 'string') ? entry.entryHash : null;
|
|
616
1441
|
|
|
617
1442
|
const li = document.createElement('li');
|
|
1443
|
+
li.dataset.outcome = outcome;
|
|
1444
|
+
li.dataset.tool = tool;
|
|
1445
|
+
if (originAgent) li.dataset.agent = originAgent;
|
|
1446
|
+
if (seq !== null) li.dataset.seq = String(seq);
|
|
1447
|
+
if (entryHash) li.dataset.hash = entryHash;
|
|
1448
|
+
// P12 advanced — short hash (8 chars) revealed by the Advanced toggle.
|
|
1449
|
+
// Stored on the row regardless; CSS hides it by default.
|
|
1450
|
+
const shortHash = entryHash ? entryHash.slice(0, 8) : '—';
|
|
618
1451
|
li.innerHTML =
|
|
1452
|
+
'<span class="seq" title="Chain sequence number">' + (seq !== null ? '#' + seq : '—') + '</span>' +
|
|
1453
|
+
'<span class="hash" title="' + (entryHash ? 'entryHash: ' + entryHash : 'no chain hash') + '">' + shortHash + '</span>' +
|
|
619
1454
|
'<span class="ts">' + escape(ts) + '</span>' +
|
|
1455
|
+
(originAgent ? agentBadgeHTML(originAgent) : '') +
|
|
620
1456
|
'<span class="outcome ' + outcome + '">' + outcome + '</span>' +
|
|
621
1457
|
'<span class="tool">' + escape(tool) + '</span>' +
|
|
622
1458
|
'<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' +
|
|
623
1459
|
'<span class="score ' + (score ? '' : 'zero') + '">' + (score ? score : '—') + '</span>';
|
|
624
1460
|
feed.insertBefore(li, feed.firstChild);
|
|
625
|
-
|
|
626
|
-
countEl.textContent = String(count);
|
|
1461
|
+
applyFilterToRow(li);
|
|
627
1462
|
empty.style.display = 'none';
|
|
628
1463
|
}
|
|
629
1464
|
|
|
1465
|
+
// Bug A5 — filter state. Active outcome filter and active tool filter
|
|
1466
|
+
// (each null means "no filter"). Persisted in the URL hash so a
|
|
1467
|
+
// bookmarkable view is achievable + reload preserves the filter.
|
|
1468
|
+
const filterState = { outcome: null, tool: null };
|
|
1469
|
+
function readFilterFromHash() {
|
|
1470
|
+
const hash = (location.hash || '').replace(/^#/, '');
|
|
1471
|
+
if (!hash) return;
|
|
1472
|
+
const params = new URLSearchParams(hash);
|
|
1473
|
+
const o = params.get('outcome');
|
|
1474
|
+
const t = params.get('tool');
|
|
1475
|
+
if (o && o !== 'all') filterState.outcome = o;
|
|
1476
|
+
if (t) filterState.tool = t;
|
|
1477
|
+
}
|
|
1478
|
+
function writeFilterToHash() {
|
|
1479
|
+
const params = new URLSearchParams();
|
|
1480
|
+
if (filterState.outcome) params.set('outcome', filterState.outcome);
|
|
1481
|
+
if (filterState.tool) params.set('tool', filterState.tool);
|
|
1482
|
+
const next = params.toString();
|
|
1483
|
+
location.hash = next ? '#' + next : '';
|
|
1484
|
+
}
|
|
1485
|
+
function rowMatches(li) {
|
|
1486
|
+
const o = li.dataset.outcome || '';
|
|
1487
|
+
const t = li.dataset.tool || '';
|
|
1488
|
+
if (filterState.outcome && o !== filterState.outcome) return false;
|
|
1489
|
+
if (filterState.tool && t !== filterState.tool) return false;
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
function applyFilterToRow(li) {
|
|
1493
|
+
if (rowMatches(li)) li.classList.add('feed-match');
|
|
1494
|
+
else li.classList.remove('feed-match');
|
|
1495
|
+
}
|
|
1496
|
+
function applyFilterToAllRows() {
|
|
1497
|
+
const active = !!(filterState.outcome || filterState.tool);
|
|
1498
|
+
feed.classList.toggle('filtered', active);
|
|
1499
|
+
Array.prototype.forEach.call(feed.querySelectorAll('li'), applyFilterToRow);
|
|
1500
|
+
}
|
|
1501
|
+
function syncChipUI() {
|
|
1502
|
+
const chips = document.querySelectorAll('#feed-filters .filter-chip');
|
|
1503
|
+
Array.prototype.forEach.call(chips, function(chip) {
|
|
1504
|
+
const fk = chip.dataset.filter;
|
|
1505
|
+
const fv = chip.dataset.value;
|
|
1506
|
+
if (fk === 'clear') return;
|
|
1507
|
+
let isActive = false;
|
|
1508
|
+
if (fk === 'outcome') {
|
|
1509
|
+
isActive = (fv === 'all' && !filterState.outcome) || (fv !== 'all' && filterState.outcome === fv);
|
|
1510
|
+
} else if (fk === 'tool') {
|
|
1511
|
+
isActive = filterState.tool === fv;
|
|
1512
|
+
}
|
|
1513
|
+
chip.classList.toggle('active', isActive);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
document.addEventListener('click', function(ev) {
|
|
1517
|
+
const chip = ev.target && ev.target.closest && ev.target.closest('.filter-chip');
|
|
1518
|
+
if (!chip) return;
|
|
1519
|
+
const fk = chip.dataset.filter;
|
|
1520
|
+
const fv = chip.dataset.value;
|
|
1521
|
+
if (fk === 'clear') {
|
|
1522
|
+
filterState.outcome = null;
|
|
1523
|
+
filterState.tool = null;
|
|
1524
|
+
} else if (fk === 'outcome') {
|
|
1525
|
+
filterState.outcome = fv === 'all' ? null : fv;
|
|
1526
|
+
} else if (fk === 'tool') {
|
|
1527
|
+
// Toggle: clicking an already-active tool chip clears the tool filter.
|
|
1528
|
+
filterState.tool = filterState.tool === fv ? null : fv;
|
|
1529
|
+
}
|
|
1530
|
+
writeFilterToHash();
|
|
1531
|
+
syncChipUI();
|
|
1532
|
+
applyFilterToAllRows();
|
|
1533
|
+
});
|
|
1534
|
+
// Init from URL hash
|
|
1535
|
+
readFilterFromHash();
|
|
1536
|
+
syncChipUI();
|
|
1537
|
+
|
|
630
1538
|
// ── Status strip (answers C/A/B/D) ───────────────────────────────────
|
|
631
1539
|
const events = []; // accumulate for rolling-window stats
|
|
632
1540
|
const HOUR_MS = 60 * 60 * 1000;
|
|
@@ -686,6 +1594,91 @@ ${SHARED_STYLES}
|
|
|
686
1594
|
|
|
687
1595
|
// Keep "Xs/Xm ago" fresh even when no new events arrive
|
|
688
1596
|
setInterval(() => { if (events.length) recomputeStats(); }, 1000);
|
|
1597
|
+
|
|
1598
|
+
// P12 slice — poll /api/audit/verify every 60s; show the tamper
|
|
1599
|
+
// banner if the hash chain breaks. First poll runs at load.
|
|
1600
|
+
function pollAuditChain() {
|
|
1601
|
+
fetch('/api/audit/verify')
|
|
1602
|
+
.then(r => r.ok ? r.json() : null)
|
|
1603
|
+
.then(d => {
|
|
1604
|
+
if (!d) return;
|
|
1605
|
+
const banner = document.getElementById('tamper-banner');
|
|
1606
|
+
const detail = document.getElementById('tb-detail');
|
|
1607
|
+
if (!banner) return;
|
|
1608
|
+
if (d.valid) {
|
|
1609
|
+
banner.dataset.visible = 'false';
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
// claim_user_persona HIGH — banner hidden from non-technical
|
|
1613
|
+
// users (CSS gates on body.advanced-on). data-visible flag
|
|
1614
|
+
// tells CSS whether to show when advanced toggle flips on.
|
|
1615
|
+
// Severity-driven copy below stays for technical readers.
|
|
1616
|
+
const errs = Array.isArray(d.errors) ? d.errors : [];
|
|
1617
|
+
const hasHashMismatch = errs.some(function(e) { return e.reason === 'hash_mismatch' || e.reason === 'parse_failure'; });
|
|
1618
|
+
const titleEl = banner.querySelector('.tb-text');
|
|
1619
|
+
const iconEl = banner.querySelector('.tb-icon');
|
|
1620
|
+
if (hasHashMismatch) {
|
|
1621
|
+
banner.dataset.severity = 'tamper';
|
|
1622
|
+
if (titleEl) titleEl.textContent = 'Audit log tampering detected.';
|
|
1623
|
+
if (iconEl) iconEl.textContent = '⚠';
|
|
1624
|
+
} else {
|
|
1625
|
+
banner.dataset.severity = 'anomaly';
|
|
1626
|
+
if (titleEl) titleEl.textContent = 'Audit chain anomaly (likely a daemon restart).';
|
|
1627
|
+
if (iconEl) iconEl.textContent = 'ℹ';
|
|
1628
|
+
}
|
|
1629
|
+
banner.dataset.visible = 'true';
|
|
1630
|
+
if (detail && errs.length > 0) {
|
|
1631
|
+
const first = errs[0];
|
|
1632
|
+
detail.textContent = '· ' + (errs.length === 1
|
|
1633
|
+
? 'line ' + first.lineNumber + ': ' + first.reason
|
|
1634
|
+
: errs.length + ' issues (first at line ' + first.lineNumber + ')');
|
|
1635
|
+
}
|
|
1636
|
+
})
|
|
1637
|
+
.catch(() => { /* leave banner state */ });
|
|
1638
|
+
}
|
|
1639
|
+
pollAuditChain();
|
|
1640
|
+
setInterval(pollAuditChain, 60000);
|
|
1641
|
+
|
|
1642
|
+
// P12 advanced toggle — flip body.advanced-on. Persisted via the URL
|
|
1643
|
+
// hash (?advanced=1) so reload preserves. Click toggles back off.
|
|
1644
|
+
const advancedToggle = document.getElementById('advanced-toggle');
|
|
1645
|
+
function setAdvanced(on) {
|
|
1646
|
+
document.body.classList.toggle('advanced-on', !!on);
|
|
1647
|
+
if (advancedToggle) advancedToggle.classList.toggle('active', !!on);
|
|
1648
|
+
try {
|
|
1649
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1650
|
+
if (on) params.set('advanced', '1'); else params.delete('advanced');
|
|
1651
|
+
const next = params.toString();
|
|
1652
|
+
location.hash = next ? '#' + next : '';
|
|
1653
|
+
} catch { /* hash write best-effort */ }
|
|
1654
|
+
}
|
|
1655
|
+
// Hash-on-load read
|
|
1656
|
+
try {
|
|
1657
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1658
|
+
if (params.get('advanced') === '1') setAdvanced(true);
|
|
1659
|
+
if (params.get('more') === '1') setMoreFilters(true);
|
|
1660
|
+
} catch { /* ignore */ }
|
|
1661
|
+
if (advancedToggle) advancedToggle.addEventListener('click', function() {
|
|
1662
|
+
setAdvanced(!document.body.classList.contains('advanced-on'));
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
// claim_user_persona HIGH — More-filters disclosure for the per-tool
|
|
1666
|
+
// + per-agent chips. Default closed so non-technical users see 4
|
|
1667
|
+
// chips instead of 14.
|
|
1668
|
+
const moreToggle = document.getElementById('more-filters-toggle');
|
|
1669
|
+
function setMoreFilters(on) {
|
|
1670
|
+
document.body.classList.toggle('more-filters-on', !!on);
|
|
1671
|
+
if (moreToggle) moreToggle.classList.toggle('active', !!on);
|
|
1672
|
+
try {
|
|
1673
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1674
|
+
if (on) params.set('more', '1'); else params.delete('more');
|
|
1675
|
+
const next = params.toString();
|
|
1676
|
+
location.hash = next ? '#' + next : '';
|
|
1677
|
+
} catch { /* hash write best-effort */ }
|
|
1678
|
+
}
|
|
1679
|
+
if (moreToggle) moreToggle.addEventListener('click', function() {
|
|
1680
|
+
setMoreFilters(!document.body.classList.contains('more-filters-on'));
|
|
1681
|
+
});
|
|
689
1682
|
})();
|
|
690
1683
|
</script>
|
|
691
1684
|
</body>
|
|
@@ -710,11 +1703,6 @@ ${SHARED_STYLES}
|
|
|
710
1703
|
display: flex; align-items: center; justify-content: space-between;
|
|
711
1704
|
}
|
|
712
1705
|
header .brand { display: flex; align-items: center; gap: 14px; }
|
|
713
|
-
header .lights { display: inline-flex; gap: 6px; }
|
|
714
|
-
header .lights span { width: 11px; height: 11px; border-radius: 50%; border: 1px solid transparent; }
|
|
715
|
-
header .lights .r { background: var(--block); }
|
|
716
|
-
header .lights .y { background: var(--warn); }
|
|
717
|
-
header .lights .g { background: var(--allow); }
|
|
718
1706
|
header .title { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
719
1707
|
header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
|
|
720
1708
|
header nav { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; }
|
|
@@ -794,6 +1782,20 @@ ${SHARED_STYLES}
|
|
|
794
1782
|
color: var(--fg-30); font-size: 10.5px;
|
|
795
1783
|
padding: 8px; text-align: center;
|
|
796
1784
|
}
|
|
1785
|
+
/* Pencil rename button — was missing from TASK_HTML's style block, so the
|
|
1786
|
+
SVG rendered without sizing constraints and showed as a white square on
|
|
1787
|
+
the active project row. Same rules as THREAD_HTML's copy. */
|
|
1788
|
+
.channel-rename {
|
|
1789
|
+
background: transparent; border: none; cursor: pointer;
|
|
1790
|
+
color: var(--fg-30); padding: 4px;
|
|
1791
|
+
transition: color 0.12s ease;
|
|
1792
|
+
flex: 0 0 auto;
|
|
1793
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1794
|
+
outline: none;
|
|
1795
|
+
}
|
|
1796
|
+
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
1797
|
+
.channel-rename:hover, .channel-rename:focus { color: var(--fg); background: transparent; }
|
|
1798
|
+
.channel-rename:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
|
797
1799
|
|
|
798
1800
|
/* ── Readiness banner (F1a) ─────────────────────────────────────────── */
|
|
799
1801
|
.readiness {
|
|
@@ -829,6 +1831,61 @@ ${SHARED_STYLES}
|
|
|
829
1831
|
}
|
|
830
1832
|
.readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
831
1833
|
|
|
1834
|
+
/* v0.6 S2 — first-time onboarding card. Quiet authority: peach
|
|
1835
|
+
accent border + subtle glass, doesn't compete with the textarea
|
|
1836
|
+
below. Removed once dismissed OR once any session exists. */
|
|
1837
|
+
.onboarding-card {
|
|
1838
|
+
margin: 30px 0 18px; padding: 24px 28px;
|
|
1839
|
+
border: 1px solid rgba(255,154,118,0.25);
|
|
1840
|
+
background: linear-gradient(135deg, rgba(255,154,118,0.04) 0%, rgba(74,144,226,0.03) 100%);
|
|
1841
|
+
border-radius: 12px;
|
|
1842
|
+
position: relative; z-index: 1;
|
|
1843
|
+
}
|
|
1844
|
+
.onboarding-card .ob-head {
|
|
1845
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
1846
|
+
margin-bottom: 6px;
|
|
1847
|
+
}
|
|
1848
|
+
.onboarding-card .ob-title {
|
|
1849
|
+
font-family: var(--sans); font-size: 18px; font-weight: 700;
|
|
1850
|
+
color: var(--fg);
|
|
1851
|
+
background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
|
|
1852
|
+
-webkit-background-clip: text; background-clip: text;
|
|
1853
|
+
-webkit-text-fill-color: transparent;
|
|
1854
|
+
}
|
|
1855
|
+
.onboarding-card .ob-close {
|
|
1856
|
+
background: transparent; border: none; cursor: pointer;
|
|
1857
|
+
color: var(--fg-40); font-size: 22px; line-height: 1;
|
|
1858
|
+
padding: 0 4px;
|
|
1859
|
+
transition: color 0.15s ease;
|
|
1860
|
+
}
|
|
1861
|
+
.onboarding-card .ob-close:hover { color: var(--fg); }
|
|
1862
|
+
.onboarding-card .ob-blurb {
|
|
1863
|
+
font-family: var(--sans); font-size: 13px; line-height: 1.55;
|
|
1864
|
+
color: var(--fg-70); margin-bottom: 16px; max-width: 640px;
|
|
1865
|
+
}
|
|
1866
|
+
.onboarding-card .ob-steps {
|
|
1867
|
+
list-style: none; padding: 0; margin: 0;
|
|
1868
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
1869
|
+
}
|
|
1870
|
+
.onboarding-card .ob-steps li {
|
|
1871
|
+
display: flex; gap: 12px; align-items: flex-start;
|
|
1872
|
+
}
|
|
1873
|
+
.onboarding-card .ob-num {
|
|
1874
|
+
flex: 0 0 22px; height: 22px; line-height: 22px;
|
|
1875
|
+
border-radius: 50%; text-align: center;
|
|
1876
|
+
background: rgba(255,154,118,0.15);
|
|
1877
|
+
color: var(--accent);
|
|
1878
|
+
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
|
1879
|
+
}
|
|
1880
|
+
.onboarding-card .ob-step-title {
|
|
1881
|
+
font-family: var(--sans); font-size: 13px; font-weight: 600;
|
|
1882
|
+
color: var(--fg); margin-bottom: 2px;
|
|
1883
|
+
}
|
|
1884
|
+
.onboarding-card .ob-step-body {
|
|
1885
|
+
font-family: var(--sans); font-size: 12px; line-height: 1.5;
|
|
1886
|
+
color: var(--fg-50);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
832
1889
|
.intake { padding: 36px 0 24px; position: relative; z-index: 1; }
|
|
833
1890
|
.intake h1 {
|
|
834
1891
|
margin: 0 0 6px;
|
|
@@ -847,6 +1904,47 @@ ${SHARED_STYLES}
|
|
|
847
1904
|
resize: vertical; outline: none;
|
|
848
1905
|
}
|
|
849
1906
|
.intake textarea:focus { border-color: rgba(255,154,118,0.4); }
|
|
1907
|
+
|
|
1908
|
+
/* Pattern 5 · Composer + cost row. Sits between the textarea and the
|
|
1909
|
+
intake-row so the user sees: who's going to run this · where ·
|
|
1910
|
+
how much of the context window the prompt consumes · est. cost.
|
|
1911
|
+
Live-updates on every keystroke + agent/cwd change.
|
|
1912
|
+
Per docs/design/AGENTIC_UX.md Pattern 5. */
|
|
1913
|
+
.composer-meta {
|
|
1914
|
+
display: flex; gap: 10px; align-items: center;
|
|
1915
|
+
margin-top: 6px; padding: 0 4px;
|
|
1916
|
+
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
1917
|
+
min-height: 18px;
|
|
1918
|
+
}
|
|
1919
|
+
.composer-meta .cm-pill {
|
|
1920
|
+
display: inline-flex; gap: 4px; align-items: center;
|
|
1921
|
+
padding: 1px 8px; border-radius: 999px;
|
|
1922
|
+
border: 1px solid var(--border-soft);
|
|
1923
|
+
background: rgba(255,255,255,0.02);
|
|
1924
|
+
}
|
|
1925
|
+
.composer-meta .cm-pill b { color: var(--fg-70); font-weight: 500; }
|
|
1926
|
+
.composer-meta .cm-sep { color: var(--fg-20); user-select: none; }
|
|
1927
|
+
.composer-meta .cm-budget[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1928
|
+
.composer-meta .cm-budget[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1929
|
+
.composer-meta .cm-cost.free { color: var(--allow); border-color: rgba(40,200,64,0.22); background: rgba(40,200,64,0.06); }
|
|
1930
|
+
.composer-meta .cm-cost.paid { color: var(--fg-70); }
|
|
1931
|
+
.composer-meta .cm-spend { color: var(--fg-70); cursor: help; }
|
|
1932
|
+
.composer-meta .cm-spend b { color: var(--fg-90); }
|
|
1933
|
+
.composer-meta .cm-spend[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1934
|
+
.composer-meta .cm-spend[data-state="warn"] b{ color: var(--warn); }
|
|
1935
|
+
.composer-meta .cm-spend[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1936
|
+
.composer-meta .cm-spend[data-state="block"] b{ color: var(--block); }
|
|
1937
|
+
.composer-meta .cm-spacer { flex: 1; }
|
|
1938
|
+
.composer-meta .cm-hint {
|
|
1939
|
+
color: var(--fg-30); font-size: 10.5px;
|
|
1940
|
+
margin-left: 4px;
|
|
1941
|
+
}
|
|
1942
|
+
/* On narrow viewports the hint hides — agent + budget pills stay,
|
|
1943
|
+
keyboard hint becomes discoverable via tooltip on the textarea. */
|
|
1944
|
+
@media (max-width: 720px) {
|
|
1945
|
+
.composer-meta .cm-hint { display: none; }
|
|
1946
|
+
}
|
|
1947
|
+
|
|
850
1948
|
.intake-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
|
851
1949
|
.intake select, .intake button, .intake input.cwd {
|
|
852
1950
|
font-family: var(--mono); font-size: 12px;
|
|
@@ -1005,7 +2103,7 @@ ${SHARED_STYLES}
|
|
|
1005
2103
|
}
|
|
1006
2104
|
.session-head {
|
|
1007
2105
|
display: flex; align-items: center; justify-content: space-between;
|
|
1008
|
-
padding:
|
|
2106
|
+
padding: 14px 20px;
|
|
1009
2107
|
cursor: pointer;
|
|
1010
2108
|
user-select: none;
|
|
1011
2109
|
}
|
|
@@ -1023,20 +2121,79 @@ ${SHARED_STYLES}
|
|
|
1023
2121
|
.session-head .meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
1024
2122
|
.session-head .prompt { font-family: var(--mono); font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 620px; }
|
|
1025
2123
|
.session-head .sub { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
|
|
2124
|
+
/* May 6 2026 design polish (Tier 1 #2): drop uppercase + heavy letter-spacing
|
|
2125
|
+
per Open-Agents production-grade reference. Status pills now read as
|
|
2126
|
+
human labels (Sentence case) not all-caps shouts. Killed demoted from
|
|
2127
|
+
yellow-warn to muted neutral — it's just "stopped", not an alert. */
|
|
1026
2128
|
.session-head .status-pill {
|
|
1027
|
-
font-family: var(--mono); font-size:
|
|
1028
|
-
text-transform: uppercase; letter-spacing: 0.08em;
|
|
2129
|
+
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
|
|
1029
2130
|
padding: 3px 10px; border-radius: 999px;
|
|
1030
2131
|
border: 1px solid transparent;
|
|
1031
2132
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
.status-pill.
|
|
2133
|
+
/* Pattern 8 · Status badge taxonomy — locked palette per Track A #2.
|
|
2134
|
+
7 owned states; semantic colors reflect actionable meaning.
|
|
2135
|
+
Per docs/design/AGENTIC_UX.md Pattern 8. */
|
|
2136
|
+
.status-pill.running { color: var(--accent); background: var(--accent-wash); border-color: rgba(255,154,118,0.25); }
|
|
2137
|
+
.status-pill.completed { color: var(--allow); background: rgba(40,200,64,0.10); border-color: rgba(40,200,64,0.20); }
|
|
2138
|
+
.status-pill.awaiting { color: var(--accent); background: transparent; border-color: var(--accent); border-style: dashed; }
|
|
2139
|
+
.status-pill.blocked { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
|
|
2140
|
+
.status-pill.failed,
|
|
2141
|
+
.status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2142
|
+
.status-pill.killed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
|
|
1036
2143
|
.status-pill.observed-pill { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); font-weight: 400; }
|
|
1037
2144
|
|
|
2145
|
+
/* Pattern 6 · macro states — wired to actual adapter error codes
|
|
2146
|
+
emitted by failover-classifier.ts + per-adapter ERROR_PATTERNS.
|
|
2147
|
+
yellow (--warn) = recoverable (failover-eligible per failover-classifier);
|
|
2148
|
+
red (--block) = needs human action (auth, billing, model-pick).
|
|
2149
|
+
Per docs/design/AGENTIC_UX.md Pattern 6. */
|
|
2150
|
+
.status-pill.rate-limit,
|
|
2151
|
+
.status-pill.quota-exceeded,
|
|
2152
|
+
.status-pill.credit-low { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
|
|
2153
|
+
.status-pill.auth-invalid,
|
|
2154
|
+
.status-pill.auth-missing,
|
|
2155
|
+
.status-pill.model-unavailable,
|
|
2156
|
+
.status-pill.crashed { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2157
|
+
.status-pill.thinking,
|
|
2158
|
+
.status-pill.running-buffered { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); }
|
|
2159
|
+
|
|
2160
|
+
/* Persisted sessions = your own past tasks replayed from disk.
|
|
2161
|
+
Faded but VISIBLE. The detected-process variant (.observed) is
|
|
2162
|
+
diagnostic — hidden by default per claim_user_persona. */
|
|
2163
|
+
.session.persisted { opacity: 0.88; }
|
|
2164
|
+
.session.persisted .session-head { border-bottom: none; }
|
|
1038
2165
|
.session.observed { opacity: 0.88; }
|
|
1039
2166
|
.session.observed .session-head { border-bottom: none; }
|
|
2167
|
+
/* claim_user_persona HIGH — detected-process rows ("Claude Code ·
|
|
2168
|
+
pid 30531") are diagnostic, not user-facing. Hidden by default;
|
|
2169
|
+
advanced toggle reveals. The user's OWN spawned + persisted
|
|
2170
|
+
sessions still show — only externally-detected runners are gated. */
|
|
2171
|
+
.session.observed { display: none; }
|
|
2172
|
+
body.advanced-on .session.observed { display: block; }
|
|
2173
|
+
|
|
2174
|
+
/* claim_user_persona — session list limit. Default shows the 5
|
|
2175
|
+
most-recent cards; "Show all" reveals the rest. .show-all-on body
|
|
2176
|
+
class lifts the cap. CSS-only via :nth-child so attachSession
|
|
2177
|
+
prepend semantics stay simple. */
|
|
2178
|
+
#sessions > .session:nth-child(n+6) { display: none; }
|
|
2179
|
+
body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
|
|
2180
|
+
/* When advanced is also off, observed rows beyond cap stay hidden. */
|
|
2181
|
+
body.show-all-sessions #sessions > .session.observed { display: none; }
|
|
2182
|
+
body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
|
|
2183
|
+
.show-more-sessions {
|
|
2184
|
+
display: none; /* shown via JS when count > 5 */
|
|
2185
|
+
margin: 14px auto;
|
|
2186
|
+
padding: 8px 16px;
|
|
2187
|
+
background: transparent;
|
|
2188
|
+
color: var(--fg-50);
|
|
2189
|
+
border: 1px solid var(--border);
|
|
2190
|
+
border-radius: 999px;
|
|
2191
|
+
font-family: var(--mono); font-size: 11px;
|
|
2192
|
+
cursor: pointer;
|
|
2193
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
2194
|
+
}
|
|
2195
|
+
.show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2196
|
+
body.show-all-sessions .show-more-sessions { display: none; }
|
|
1040
2197
|
|
|
1041
2198
|
.session-controls { display: flex; gap: 8px; }
|
|
1042
2199
|
.session-controls button {
|
|
@@ -1047,8 +2204,139 @@ ${SHARED_STYLES}
|
|
|
1047
2204
|
}
|
|
1048
2205
|
.session-controls button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1049
2206
|
.session-controls button.kill:hover { color: var(--block); border-color: rgba(255,95,87,0.4); }
|
|
2207
|
+
/* Pattern 19 — interrupt affordance state machine (ready / confirming / killing).
|
|
2208
|
+
Confirming state turns the button red so the second press registers
|
|
2209
|
+
as intentional. 3s window then reverts to ready. */
|
|
2210
|
+
.session-controls button.kill[data-state="confirming"] {
|
|
2211
|
+
color: var(--block);
|
|
2212
|
+
border-color: var(--block);
|
|
2213
|
+
background: rgba(255,95,87,0.08);
|
|
2214
|
+
font-weight: 600;
|
|
2215
|
+
animation: kill-confirm-pulse 0.6s ease-in-out infinite alternate;
|
|
2216
|
+
}
|
|
2217
|
+
.session-controls button.kill[data-state="killing"] {
|
|
2218
|
+
color: var(--fg-50);
|
|
2219
|
+
cursor: progress;
|
|
2220
|
+
opacity: 0.7;
|
|
2221
|
+
}
|
|
2222
|
+
@keyframes kill-confirm-pulse {
|
|
2223
|
+
from { box-shadow: 0 0 0 rgba(255,95,87,0); }
|
|
2224
|
+
to { box-shadow: 0 0 8px rgba(255,95,87,0.4); }
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
.session-body { padding: 0 20px 16px; }
|
|
2228
|
+
|
|
2229
|
+
/* P18 slice 4 — permission gate panel. Renders inside the session
|
|
2230
|
+
card when the per-tool hook blocked something. Per the dogfood-
|
|
2231
|
+
learning + overconfidence-guard: framed as "Codehere noticed",
|
|
2232
|
+
not "security violation". Approve button is the most prominent
|
|
2233
|
+
because false-positives are the common case (e.g. /etc/shadow
|
|
2234
|
+
in a docstring). Keep-blocking is the rare-but-real branch. */
|
|
2235
|
+
.permission-gate {
|
|
2236
|
+
margin: 10px 0 8px;
|
|
2237
|
+
padding: 12px 14px;
|
|
2238
|
+
border: 1px solid rgba(254,188,46,0.35);
|
|
2239
|
+
background: rgba(254,188,46,0.05);
|
|
2240
|
+
border-radius: 8px;
|
|
2241
|
+
}
|
|
2242
|
+
.permission-gate .pg-label {
|
|
2243
|
+
font-family: var(--mono); font-size: 9.5px;
|
|
2244
|
+
color: var(--warn);
|
|
2245
|
+
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2246
|
+
margin-bottom: 4px;
|
|
2247
|
+
}
|
|
2248
|
+
.permission-gate .pg-title {
|
|
2249
|
+
font-family: var(--sans); font-size: 13px;
|
|
2250
|
+
color: var(--fg);
|
|
2251
|
+
margin-bottom: 6px;
|
|
2252
|
+
}
|
|
2253
|
+
.permission-gate .pg-detail {
|
|
2254
|
+
font-family: var(--mono); font-size: 11px;
|
|
2255
|
+
color: var(--fg-70);
|
|
2256
|
+
background: rgba(0,0,0,0.2);
|
|
2257
|
+
padding: 6px 10px; border-radius: 4px;
|
|
2258
|
+
margin-bottom: 10px;
|
|
2259
|
+
word-break: break-all;
|
|
2260
|
+
}
|
|
2261
|
+
.permission-gate .pg-actions {
|
|
2262
|
+
display: flex; gap: 8px; align-items: center;
|
|
2263
|
+
}
|
|
2264
|
+
.permission-gate button {
|
|
2265
|
+
font-family: var(--mono); font-size: 11px;
|
|
2266
|
+
padding: 6px 12px; border-radius: 6px;
|
|
2267
|
+
cursor: pointer;
|
|
2268
|
+
border: 1px solid var(--border);
|
|
2269
|
+
background: transparent;
|
|
2270
|
+
color: var(--fg-70);
|
|
2271
|
+
}
|
|
2272
|
+
.permission-gate button.pg-approve {
|
|
2273
|
+
color: var(--accent);
|
|
2274
|
+
border-color: var(--accent);
|
|
2275
|
+
background: var(--accent-wash);
|
|
2276
|
+
font-weight: 600;
|
|
2277
|
+
}
|
|
2278
|
+
.permission-gate button.pg-approve:hover { filter: brightness(1.1); }
|
|
2279
|
+
.permission-gate button.pg-keep:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2280
|
+
.permission-gate.approved {
|
|
2281
|
+
border-color: rgba(40,200,64,0.3);
|
|
2282
|
+
background: rgba(40,200,64,0.05);
|
|
2283
|
+
}
|
|
2284
|
+
.permission-gate.approved .pg-label { color: var(--allow); }
|
|
2285
|
+
.permission-gate.approved .pg-actions { display: none; }
|
|
2286
|
+
.permission-gate .pg-status {
|
|
2287
|
+
display: none;
|
|
2288
|
+
font-family: var(--mono); font-size: 10.5px;
|
|
2289
|
+
color: var(--allow);
|
|
2290
|
+
margin-top: 2px;
|
|
2291
|
+
}
|
|
2292
|
+
.permission-gate.approved .pg-status { display: block; }
|
|
2293
|
+
|
|
2294
|
+
/* v0.6 M3 — PM narrator block. Quiet style — visible but doesn't
|
|
2295
|
+
compete with primary content. Italic body to signal "this is
|
|
2296
|
+
narration, not authoritative" per overconfidence-guard. */
|
|
2297
|
+
.narrator-block {
|
|
2298
|
+
margin: 10px 0 8px;
|
|
2299
|
+
padding: 10px 14px;
|
|
2300
|
+
border-left: 2px solid var(--accent);
|
|
2301
|
+
background: rgba(255,154,118,0.04);
|
|
2302
|
+
border-radius: 0 8px 8px 0;
|
|
2303
|
+
}
|
|
2304
|
+
.narrator-block .narrator-label {
|
|
2305
|
+
font-family: var(--mono); font-size: 9.5px;
|
|
2306
|
+
color: var(--fg-30);
|
|
2307
|
+
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2308
|
+
margin-bottom: 4px;
|
|
2309
|
+
}
|
|
2310
|
+
.narrator-block .narrator-text {
|
|
2311
|
+
font-family: var(--sans);
|
|
2312
|
+
font-size: 13px; line-height: 1.45;
|
|
2313
|
+
color: var(--fg-70);
|
|
2314
|
+
font-style: italic;
|
|
2315
|
+
}
|
|
2316
|
+
.narrator-block .narrator-meta {
|
|
2317
|
+
font-family: var(--mono); font-size: 10px;
|
|
2318
|
+
color: var(--fg-30);
|
|
2319
|
+
margin-top: 4px;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/* Pattern 3 · Session card footer — elapsed · tools · files. Renders
|
|
2323
|
+
beneath the body when the session has at least one step. Closes the
|
|
2324
|
+
card visually + surfaces the same counts as the live tool-progress
|
|
2325
|
+
chip but in a settled, summary form. Cost intentionally absent here
|
|
2326
|
+
— that's P5's composer surface, not the per-card footer (per
|
|
2327
|
+
AGENTIC_UX.md Pattern 3: "elapsed · cost · tool count · file count"
|
|
2328
|
+
with cost-router not yet wired). */
|
|
2329
|
+
.session-foot {
|
|
2330
|
+
display: flex; gap: 14px; align-items: center;
|
|
2331
|
+
padding: 10px 20px 12px;
|
|
2332
|
+
border-top: 1px solid var(--border-soft);
|
|
2333
|
+
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
2334
|
+
}
|
|
2335
|
+
.session-foot[data-empty="true"] { display: none; }
|
|
2336
|
+
.session-foot .sf-item { display: inline-flex; gap: 4px; align-items: center; }
|
|
2337
|
+
.session-foot .sf-item b { color: var(--fg-70); font-weight: 600; }
|
|
2338
|
+
.session-foot .sf-sep { color: var(--fg-20); user-select: none; }
|
|
1050
2339
|
|
|
1051
|
-
.session-body { padding: 0 16px 14px; }
|
|
1052
2340
|
.event-log {
|
|
1053
2341
|
margin: 10px 0 0;
|
|
1054
2342
|
max-height: 360px; overflow-y: auto;
|
|
@@ -1212,31 +2500,78 @@ ${SHARED_STYLES}
|
|
|
1212
2500
|
<a href="/" class="nav-link active">Tasks</a>
|
|
1213
2501
|
<a href="/threads" class="nav-link">Threads</a>
|
|
1214
2502
|
<a href="/history" class="nav-link">History</a>
|
|
2503
|
+
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
2504
|
+
<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>
|
|
1215
2505
|
</nav>
|
|
1216
2506
|
|
|
1217
2507
|
<div class="shell slack-shape">
|
|
1218
2508
|
|
|
1219
2509
|
<aside class="channels-pane">
|
|
1220
|
-
<div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">
|
|
1221
|
-
<div id="channels-list">
|
|
1222
|
-
<div class="channel active" data-channel="current">
|
|
1223
|
-
<span class="channel-glyph">#</span>
|
|
1224
|
-
<div style="flex:1;min-width:0">
|
|
1225
|
-
<div class="channel-name" id="current-channel-name">project</div>
|
|
1226
|
-
<div class="channel-cwd-hint" id="current-channel-cwd">~</div>
|
|
1227
|
-
</div>
|
|
1228
|
-
</div>
|
|
1229
|
-
</div>
|
|
1230
|
-
<!-- intentionally empty: multi-project sidebar arrives in v0.6.x -->
|
|
2510
|
+
<div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">0</span></div>
|
|
2511
|
+
<div id="channels-list"><div class="pane-empty">Loading…</div></div>
|
|
1231
2512
|
</aside>
|
|
1232
2513
|
|
|
1233
2514
|
<section class="thread-pane">
|
|
1234
2515
|
|
|
1235
|
-
|
|
2516
|
+
<!-- v0.6 S2 — first-time onboarding card. Hidden once the user
|
|
2517
|
+
dismisses (localStorage flag codehere.onboarded=1) OR once any
|
|
2518
|
+
session exists in the dashboard. Per claim_user_persona HIGH:
|
|
2519
|
+
non-technical users primary; the first 5 minutes is load-bearing
|
|
2520
|
+
(feedback_first_5_minutes_is_load_bearing.md). This card guides
|
|
2521
|
+
those 5 minutes: pick an agent → type a task → watch the report. -->
|
|
2522
|
+
<div class="onboarding-card" id="onboarding-card" style="display:none">
|
|
2523
|
+
<div class="ob-head">
|
|
2524
|
+
<div class="ob-title">Welcome to codehere.</div>
|
|
2525
|
+
<button type="button" class="ob-close" id="ob-close" aria-label="Dismiss">×</button>
|
|
2526
|
+
</div>
|
|
2527
|
+
<div class="ob-blurb">A PM for your AI coding agents. Delegate a task in plain English; codehere picks an agent, watches every action, and reports back.</div>
|
|
2528
|
+
<ol class="ob-steps">
|
|
2529
|
+
<li>
|
|
2530
|
+
<span class="ob-num">1</span>
|
|
2531
|
+
<div>
|
|
2532
|
+
<div class="ob-step-title">Pick an agent — or let codehere pick</div>
|
|
2533
|
+
<div class="ob-step-body">Auto-pick is the default and works for most tasks. Specialists (Claude Code, Codex, Aider, Gemini, OpenCode) are below.</div>
|
|
2534
|
+
</div>
|
|
2535
|
+
</li>
|
|
2536
|
+
<li>
|
|
2537
|
+
<span class="ob-num">2</span>
|
|
2538
|
+
<div>
|
|
2539
|
+
<div class="ob-step-title">Type what needs doing</div>
|
|
2540
|
+
<div class="ob-step-body">Plain English. Try: "Show me what this repo does in 3 bullets" or "Run the tests and tell me what failed."</div>
|
|
2541
|
+
</div>
|
|
2542
|
+
</li>
|
|
2543
|
+
<li>
|
|
2544
|
+
<span class="ob-num">3</span>
|
|
2545
|
+
<div>
|
|
2546
|
+
<div class="ob-step-title">Watch the Final Report</div>
|
|
2547
|
+
<div class="ob-step-body">When the agent finishes, codehere generates a plain-English summary of what changed, what was tested, and what to look at next.</div>
|
|
2548
|
+
</div>
|
|
2549
|
+
</li>
|
|
2550
|
+
</ol>
|
|
2551
|
+
</div>
|
|
1236
2552
|
|
|
1237
2553
|
<div class="intake">
|
|
1238
|
-
<
|
|
1239
|
-
|
|
2554
|
+
<textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
|
|
2555
|
+
<!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
|
|
2556
|
+
dropped the cm-cwd pill — the cwd input below is the authoritative
|
|
2557
|
+
editable surface; duplicating it as a non-editable pill caused
|
|
2558
|
+
desync (pill stayed "—" while input showed the daemon-restored
|
|
2559
|
+
cwd). Folded the keyboard-shortcut hint inline-left so the prior
|
|
2560
|
+
second .intake-row could be deleted. -->
|
|
2561
|
+
<div class="composer-meta" id="composer-meta">
|
|
2562
|
+
<span class="cm-pill cm-agent" id="cm-agent"><b>Auto</b></span>
|
|
2563
|
+
<span class="cm-sep">·</span>
|
|
2564
|
+
<span class="cm-pill cm-budget" id="cm-budget" data-state="ok"><b>0%</b><span>used</span></span>
|
|
2565
|
+
<span class="cm-sep">·</span>
|
|
2566
|
+
<!-- v0.6 S3 slice 1 — today's estimated spend across every completed
|
|
2567
|
+
session in ~/.codehere/sessions.jsonl. Hidden when zero so a
|
|
2568
|
+
fresh-install dashboard isn't cluttered with "$0.00 today". Tooltip
|
|
2569
|
+
points to vendor dashboards as the authoritative number. -->
|
|
2570
|
+
<span class="cm-pill cm-spend" id="cm-spend" style="display:none" title="Estimated. Vendor dashboards are authoritative."><b>$0.00</b><span>today</span></span>
|
|
2571
|
+
<span class="cm-hint">⌘+Enter to start · ⌘+Shift+Enter to reply to last task</span>
|
|
2572
|
+
<span class="cm-spacer"></span>
|
|
2573
|
+
<span class="cm-pill cm-cost" id="cm-cost" style="display:none"></span>
|
|
2574
|
+
</div>
|
|
1240
2575
|
<div class="starter-prompts" id="starter-prompts">
|
|
1241
2576
|
<span class="sp-label">TRY</span>
|
|
1242
2577
|
<button type="button" data-prompt="Read the README and summarize what this project is in 3 short bullets.">Summarize this repo</button>
|
|
@@ -1244,15 +2579,12 @@ ${SHARED_STYLES}
|
|
|
1244
2579
|
<button type="button" data-prompt="Run the tests and tell me what (if anything) is failing.">Run the tests</button>
|
|
1245
2580
|
</div>
|
|
1246
2581
|
<div class="intake-row">
|
|
1247
|
-
<select id="agent" title="
|
|
2582
|
+
<select id="agent" title="codehere picks the right tool unless you override"><option value="">Auto-pick</option></select>
|
|
1248
2583
|
<input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
|
|
1249
2584
|
<button class="continue-thread" id="continue-thread" type="button" disabled title="Send as a reply to the most recent task in this folder (Slack-shape spike)">Continue thread</button>
|
|
1250
2585
|
<button class="spawn" id="spawn">Assign new</button>
|
|
1251
2586
|
</div>
|
|
1252
2587
|
<div class="setup-panel" id="setup-panel" data-state=""></div>
|
|
1253
|
-
<div class="intake-row" style="margin-top:6px">
|
|
1254
|
-
<span class="hint" style="margin-left:auto">⌘+Enter = Assign new · ⌘+Shift+Enter = Continue thread</span>
|
|
1255
|
-
</div>
|
|
1256
2588
|
</div>
|
|
1257
2589
|
|
|
1258
2590
|
<!-- Apr 29 — UX overhaul: readiness banner + stats demoted below intake.
|
|
@@ -1260,22 +2592,28 @@ ${SHARED_STYLES}
|
|
|
1260
2592
|
Goal: textarea is the focal point above the fold; chrome subordinates. -->
|
|
1261
2593
|
<div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
|
|
1262
2594
|
|
|
2595
|
+
<!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
|
|
2596
|
+
The prior 4-stat row mixed two definitions of "agents" (the AGENTS
|
|
2597
|
+
right-pane shows installed-and-ready agents; "agents on machine" stat
|
|
2598
|
+
showed F1d-detected running processes — empty in practice) producing
|
|
2599
|
+
a contradiction visible to users (e.g. "0 agents on machine" while the
|
|
2600
|
+
right pane shows 6). Plus "blocked" was almost always 0 and "avg trust
|
|
2601
|
+
100" had no signal. Per Open-Agents production-grade reference and
|
|
2602
|
+
claim_user_persona (HIGH non-technical-first): subtract dashboard noise.
|
|
2603
|
+
Keep only the one stat that varies meaningfully — actions today —
|
|
2604
|
+
rendered as a quiet muted line. The other IDs stay in the DOM (hidden)
|
|
2605
|
+
so existing setText calls in updateBanner don't NPE. -->
|
|
1263
2606
|
<div class="stats-line empty" id="audit-summary" style="position:relative;z-index:1">
|
|
1264
2607
|
<span><b id="as-actions">0</b> actions today</span>
|
|
1265
|
-
<span class="sep">·</span>
|
|
1266
|
-
<span><b id="sb-detected">0</b> agents on machine</span>
|
|
1267
|
-
<span class="sep">·</span>
|
|
1268
|
-
<span class="blocked"><b id="as-blocked">0</b> blocked</span>
|
|
1269
|
-
<span class="sep">·</span>
|
|
1270
|
-
<span>avg trust <b id="as-trust">—</b></span>
|
|
1271
|
-
<!-- Hidden but kept in DOM so existing JS that updates these IDs doesn't error -->
|
|
1272
|
-
<span style="display:none"><span id="sb-running">0</span><span id="sb-blocked">0</span><span id="sb-last">—</span><span id="as-agents">0</span></span>
|
|
1273
2608
|
</div>
|
|
1274
2609
|
|
|
1275
|
-
|
|
1276
|
-
|
|
2610
|
+
<!-- UX-simplification iter 3 (May 7): dropped the .glow-line peach
|
|
2611
|
+
divider here. The .section-label "Sessions" below already provides
|
|
2612
|
+
visual separation; an extra 1px gradient line was decoration not
|
|
2613
|
+
information. -->
|
|
1277
2614
|
<div class="section-label" style="position:relative;z-index:1">Sessions</div>
|
|
1278
2615
|
<div id="sessions" style="position:relative;z-index:1"></div>
|
|
2616
|
+
<button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
|
|
1279
2617
|
<div id="empty">
|
|
1280
2618
|
<div class="pill">No sessions yet. Assign one above.</div>
|
|
1281
2619
|
</div>
|
|
@@ -1301,6 +2639,170 @@ ${SHARED_STYLES}
|
|
|
1301
2639
|
const emptyEl = document.getElementById('empty');
|
|
1302
2640
|
const sessions = new Map(); // sessionId → { card, logEl, unsubscribeSse }
|
|
1303
2641
|
|
|
2642
|
+
// v0.6 S2 — first-time onboarding card. Show ONLY when:
|
|
2643
|
+
// (a) localStorage flag codehere.onboarded is unset, AND
|
|
2644
|
+
// (b) no sessions yet (empty-state).
|
|
2645
|
+
// Hide when user dismisses (sets flag) OR when first session attaches.
|
|
2646
|
+
var obCard = document.getElementById('onboarding-card');
|
|
2647
|
+
var obClose = document.getElementById('ob-close');
|
|
2648
|
+
var ONBOARDED_KEY = 'codehere.onboarded';
|
|
2649
|
+
function isOnboarded() {
|
|
2650
|
+
try { return window.localStorage.getItem(ONBOARDED_KEY) === '1'; }
|
|
2651
|
+
catch (_) { return false; }
|
|
2652
|
+
}
|
|
2653
|
+
function markOnboarded() {
|
|
2654
|
+
try { window.localStorage.setItem(ONBOARDED_KEY, '1'); }
|
|
2655
|
+
catch (_) { /* private mode: persist for the session via the var below */ }
|
|
2656
|
+
}
|
|
2657
|
+
function maybeShowOnboarding() {
|
|
2658
|
+
if (!obCard) return;
|
|
2659
|
+
if (isOnboarded()) { obCard.style.display = 'none'; return; }
|
|
2660
|
+
obCard.style.display = '';
|
|
2661
|
+
}
|
|
2662
|
+
function dismissOnboarding() {
|
|
2663
|
+
if (!obCard) return;
|
|
2664
|
+
obCard.style.display = 'none';
|
|
2665
|
+
markOnboarded();
|
|
2666
|
+
}
|
|
2667
|
+
if (obClose) obClose.addEventListener('click', dismissOnboarding);
|
|
2668
|
+
// Render decision runs immediately + after first sessions fetch
|
|
2669
|
+
// (which sets sessions Map). Hide if any sessions already exist
|
|
2670
|
+
// (returning user — onboarding doesn't help).
|
|
2671
|
+
maybeShowOnboarding();
|
|
2672
|
+
|
|
2673
|
+
// Pattern 5 — composer meta line wiring. Live-updates: agent label,
|
|
2674
|
+
// context-budget %, cost preview. Bound below to the input surfaces
|
|
2675
|
+
// (prompt textarea, agent select). UX-simplification iter (May 7):
|
|
2676
|
+
// dropped cm-cwd pill — cwd input below is the authoritative surface.
|
|
2677
|
+
const cmAgentEl = document.getElementById('cm-agent');
|
|
2678
|
+
const cmBudgetEl = document.getElementById('cm-budget');
|
|
2679
|
+
const cmCostEl = document.getElementById('cm-cost');
|
|
2680
|
+
// v0.6 S3 slice 1 — today's spend chip. Lives next to the context-budget
|
|
2681
|
+
// chip; hidden until the first non-zero spend lands so a fresh-install
|
|
2682
|
+
// dashboard isn't cluttered. Polled, not pushed (events would need
|
|
2683
|
+
// server-side fanout).
|
|
2684
|
+
const cmSpendEl = document.getElementById('cm-spend');
|
|
2685
|
+
function refreshSpendToday() {
|
|
2686
|
+
if (!cmSpendEl) return;
|
|
2687
|
+
fetch('/api/spend/today')
|
|
2688
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
2689
|
+
.then(function(d) {
|
|
2690
|
+
if (!d || typeof d.totalUsd !== 'number') return;
|
|
2691
|
+
// Show the chip when EITHER spend > 0 OR a budget is set (so the
|
|
2692
|
+
// user sees "$0.00 / $5.00" right after \`codehere budget set 5\`).
|
|
2693
|
+
var budget = d.budget || {};
|
|
2694
|
+
var hasBudget = budget.dailyUsd !== null && typeof budget.dailyUsd === 'number';
|
|
2695
|
+
if (d.totalUsd <= 0 && d.sessionCount === 0 && !hasBudget) {
|
|
2696
|
+
cmSpendEl.style.display = 'none';
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
cmSpendEl.style.display = 'inline-flex';
|
|
2700
|
+
var b = cmSpendEl.querySelector('b');
|
|
2701
|
+
if (b) {
|
|
2702
|
+
var dollars = d.totalUsd;
|
|
2703
|
+
var spendStr = dollars >= 0.01 ? '$' + dollars.toFixed(2) : '$' + dollars.toFixed(3);
|
|
2704
|
+
// When a budget is set, render "$X.XX / $Y" so the ceiling is visible.
|
|
2705
|
+
b.textContent = hasBudget
|
|
2706
|
+
? spendStr + ' / $' + budget.dailyUsd.toFixed(2)
|
|
2707
|
+
: spendStr;
|
|
2708
|
+
}
|
|
2709
|
+
// Color state — warn at warnAtPct, block when over.
|
|
2710
|
+
var state = 'ok';
|
|
2711
|
+
if (hasBudget) {
|
|
2712
|
+
if (budget.over) state = 'block';
|
|
2713
|
+
else if (typeof budget.pctUsed === 'number' && budget.pctUsed >= (budget.warnAtPct || 70)) state = 'warn';
|
|
2714
|
+
}
|
|
2715
|
+
cmSpendEl.dataset.state = state;
|
|
2716
|
+
// Tooltip: per-agent breakdown + budget context if set.
|
|
2717
|
+
var lines = ['Estimated spend today across ' + d.sessionCount + ' completed session' + (d.sessionCount === 1 ? '' : 's') + ':'];
|
|
2718
|
+
var keys = Object.keys(d.byAgent || {});
|
|
2719
|
+
keys.sort(function(a, b) { return (d.byAgent[b].usd || 0) - (d.byAgent[a].usd || 0); });
|
|
2720
|
+
for (var i = 0; i < keys.length; i++) {
|
|
2721
|
+
var k = keys[i];
|
|
2722
|
+
var v = d.byAgent[k];
|
|
2723
|
+
lines.push(' ' + k + ': $' + (v.usd >= 0.01 ? v.usd.toFixed(2) : v.usd.toFixed(3)) + ' (' + v.sessions + 'x)');
|
|
2724
|
+
}
|
|
2725
|
+
if (hasBudget) {
|
|
2726
|
+
lines.push('');
|
|
2727
|
+
lines.push('Daily budget: $' + budget.dailyUsd.toFixed(2) + ' (' + (budget.pctUsed || 0) + '% used)');
|
|
2728
|
+
if (budget.over) {
|
|
2729
|
+
lines.push('OVER BUDGET — paid spawns will be refused. Use a free agent (gemini, opencode) or run: codehere budget set <higher-amount>');
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
lines.push('Vendor dashboards are authoritative — these are tier-ceiling estimates.');
|
|
2733
|
+
cmSpendEl.title = lines.join('\\n');
|
|
2734
|
+
})
|
|
2735
|
+
.catch(function() { /* skip */ });
|
|
2736
|
+
}
|
|
2737
|
+
// Refresh on dashboard load + every 60s (sessions complete async, no point
|
|
2738
|
+
// polling faster than per-minute).
|
|
2739
|
+
refreshSpendToday();
|
|
2740
|
+
setInterval(refreshSpendToday, 60_000);
|
|
2741
|
+
|
|
2742
|
+
// Free-tier agents per feedback_adapters_must_have_free_tier.md. Cost
|
|
2743
|
+
// preview hides for these (the user pays nothing per call). Conductor
|
|
2744
|
+
// routes to one of the specialists, so its cost mirrors the picked one.
|
|
2745
|
+
const FREE_TIER_AGENTS = new Set(['gemini', 'opencode']);
|
|
2746
|
+
|
|
2747
|
+
// Per-agent rough cost estimate ($ per 1K input + 1K output tokens combined).
|
|
2748
|
+
// These are CEILINGS for ballpark ("~$0.0X likely") not precise — adapter
|
|
2749
|
+
// doesn't surface model selection, so the heuristic uses a balanced-tier
|
|
2750
|
+
// assumption. Better to under-promise; users care about scale-of-magnitude.
|
|
2751
|
+
const COST_PER_KTOK = {
|
|
2752
|
+
'claude-code': 0.018, // sonnet-tier balanced
|
|
2753
|
+
'codex': 0.020, // gpt-5-codex balanced
|
|
2754
|
+
'aider': 0.018, // typically sonnet
|
|
2755
|
+
'conductor': 0.018, // routes to claude-code by default
|
|
2756
|
+
};
|
|
2757
|
+
|
|
2758
|
+
function syncComposerMeta() {
|
|
2759
|
+
if (!cmAgentEl || !cmBudgetEl) return;
|
|
2760
|
+
// Resolve agent label. Empty value = Conductor-Auto routing.
|
|
2761
|
+
var pickedId = (agentSelect && agentSelect.value) || '';
|
|
2762
|
+
var label = pickedId
|
|
2763
|
+
? agentLabel(pickedId)
|
|
2764
|
+
: 'Auto';
|
|
2765
|
+
cmAgentEl.querySelector('b').textContent = label;
|
|
2766
|
+
// Context budget — char-count / 800K (≈ 200K-token Sonnet budget).
|
|
2767
|
+
// Cap at 100. --warn at 70, --block at 90 per spec.
|
|
2768
|
+
var chars = (promptEl && promptEl.value && promptEl.value.length) || 0;
|
|
2769
|
+
var pct = Math.min(100, Math.round((chars / 8000)));
|
|
2770
|
+
var budgetState = pct >= 90 ? 'block' : pct >= 70 ? 'warn' : 'ok';
|
|
2771
|
+
cmBudgetEl.dataset.state = budgetState;
|
|
2772
|
+
cmBudgetEl.querySelector('b').textContent = pct + '%';
|
|
2773
|
+
// Cost preview (paid agents only). Auto resolves to the conductor, so
|
|
2774
|
+
// pick the cheapest balanced-tier estimate when no agent is set.
|
|
2775
|
+
if (!cmCostEl) return;
|
|
2776
|
+
var effective = pickedId || 'conductor';
|
|
2777
|
+
if (FREE_TIER_AGENTS.has(effective)) {
|
|
2778
|
+
cmCostEl.style.display = 'inline-flex';
|
|
2779
|
+
cmCostEl.className = 'cm-pill cm-cost free';
|
|
2780
|
+
cmCostEl.innerHTML = '<b>free tier</b>';
|
|
2781
|
+
cmCostEl.title = effective + ' has a free tier — no per-call billing';
|
|
2782
|
+
} else if (COST_PER_KTOK[effective] && chars > 20) {
|
|
2783
|
+
// Rough heuristic: input tokens ≈ chars/4, output ~2× input for code
|
|
2784
|
+
// tasks; clamp to a sensible floor. Show 2 decimals when > 0.01,
|
|
2785
|
+
// 3 decimals otherwise so 4¢ doesn't render as "$0.04".
|
|
2786
|
+
var inputTok = Math.ceil(chars / 4);
|
|
2787
|
+
var outputTok = Math.max(500, Math.min(3000, inputTok * 2));
|
|
2788
|
+
var totalK = (inputTok + outputTok) / 1000;
|
|
2789
|
+
var dollars = totalK * COST_PER_KTOK[effective];
|
|
2790
|
+
var dollarsStr = dollars >= 0.01
|
|
2791
|
+
? '~$' + (Math.round(dollars * 100) / 100).toFixed(2)
|
|
2792
|
+
: '~$' + (Math.round(dollars * 1000) / 1000).toFixed(3);
|
|
2793
|
+
cmCostEl.style.display = 'inline-flex';
|
|
2794
|
+
cmCostEl.className = 'cm-pill cm-cost paid';
|
|
2795
|
+
cmCostEl.innerHTML = '<b>' + dollarsStr + '</b><span> est.</span>';
|
|
2796
|
+
cmCostEl.title = 'Estimate: ' + inputTok + ' input + ~' + outputTok + ' output tokens at balanced-tier rates';
|
|
2797
|
+
} else {
|
|
2798
|
+
cmCostEl.style.display = 'none';
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (promptEl) promptEl.addEventListener('input', syncComposerMeta);
|
|
2802
|
+
if (agentSelect) agentSelect.addEventListener('change', syncComposerMeta);
|
|
2803
|
+
// Initial paint.
|
|
2804
|
+
syncComposerMeta();
|
|
2805
|
+
|
|
1304
2806
|
// E3 · Populate agent dropdown from per-agent readiness.
|
|
1305
2807
|
// Setup-aware: shows every registered agent with a state glyph (not just
|
|
1306
2808
|
// installed ones). Selecting a non-ready agent unfolds the setup panel with
|
|
@@ -1331,14 +2833,30 @@ ${SHARED_STYLES}
|
|
|
1331
2833
|
const sorted = [...agentReadiness].sort((a, b) => (weight[a.state] ?? 99) - (weight[b.state] ?? 99));
|
|
1332
2834
|
const autoState = autoReadiness ? autoReadiness.state : 'ready';
|
|
1333
2835
|
const autoGlyph = autoState === 'ready' ? '⚡' : (STATE_GLYPH[autoState] || '⚠');
|
|
1334
|
-
const autoLabel = autoState === 'ready' ? 'Auto
|
|
2836
|
+
const autoLabel = autoState === 'ready' ? 'Auto-pick (codehere picks for you)' : 'Auto-pick (install an agent first)';
|
|
1335
2837
|
const autoOption = '<option value="" selected>' + autoGlyph + ' ' + autoLabel + '</option>';
|
|
1336
|
-
const specialistOptions = sorted.map(a =>
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2838
|
+
const specialistOptions = sorted.map(a => {
|
|
2839
|
+
// v0.6 S5 — adapter description surfaces as the option's hover title so
|
|
2840
|
+
// non-technical users get a plain-English "when to use me" hint without
|
|
2841
|
+
// having to read docs. Falls back to label-only when adapter has none.
|
|
2842
|
+
const titleAttr = a.description
|
|
2843
|
+
? ' title="' + escape(a.description) + '"'
|
|
2844
|
+
: '';
|
|
2845
|
+
return '<option value="' + escape(a.id) + '"' + titleAttr + '>' +
|
|
2846
|
+
(STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
|
|
2847
|
+
'</option>';
|
|
2848
|
+
}).join('');
|
|
2849
|
+
// M5 slice 2 — fan-out option. Sends the same prompt to every
|
|
2850
|
+
// installed specialist in parallel via /api/task/batch. Per
|
|
2851
|
+
// claim_codehere_shape v5 + Decision 22 (every coding agent a
|
|
2852
|
+
// first-class member), cross-check spawning is the v1.0 value
|
|
2853
|
+
// prop. Hidden when fewer than 2 specialists are ready (no
|
|
2854
|
+
// parallel value with one). Capped at 5 by the batch endpoint.
|
|
2855
|
+
const readyCount = sorted.filter(a => a.state === 'ready').length;
|
|
2856
|
+
const fanOutOption = readyCount >= 2
|
|
2857
|
+
? '<option value="*">⚡⚡ Send to every installed agent (' + readyCount + ' in parallel)</option>'
|
|
2858
|
+
: '';
|
|
2859
|
+
agentSelect.innerHTML = autoOption + fanOutOption + specialistOptions;
|
|
1342
2860
|
// Trigger the panel render for whatever is selected first
|
|
1343
2861
|
updateSetupPanel();
|
|
1344
2862
|
}
|
|
@@ -1532,6 +3050,7 @@ ${SHARED_STYLES}
|
|
|
1532
3050
|
(a.state || 'unknown');
|
|
1533
3051
|
return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
|
|
1534
3052
|
'<span class="member-presence ' + cls + '">' + glyph + '</span>' +
|
|
3053
|
+
agentBadgeHTML(a.id) + ' ' +
|
|
1535
3054
|
'<span class="member-label">' + escape(a.label || a.id) + '</span>' +
|
|
1536
3055
|
'</div>';
|
|
1537
3056
|
}).join('');
|
|
@@ -1565,22 +3084,147 @@ ${SHARED_STYLES}
|
|
|
1565
3084
|
// daemon already knows where it was launched and that's usually the right cwd.
|
|
1566
3085
|
const savedCwd = localStorage.getItem('codehere:cwd');
|
|
1567
3086
|
if (savedCwd) cwdInput.value = savedCwd;
|
|
3087
|
+
|
|
3088
|
+
// Track daemon cwd for active-channel highlight in the projects sidebar.
|
|
3089
|
+
// Refreshed on every /api/readiness call; multi-project sidebar reads it.
|
|
3090
|
+
let daemonCwdForActive = null;
|
|
3091
|
+
|
|
1568
3092
|
fetch('/api/readiness').then(r => r.json()).then(r => {
|
|
1569
3093
|
const daemonCwd = r && r.detected && r.detected.daemonCwd;
|
|
1570
3094
|
if (daemonCwd) {
|
|
1571
3095
|
cwdInput.placeholder = daemonCwd;
|
|
1572
3096
|
if (!cwdInput.value.trim()) cwdInput.value = daemonCwd; // auto-fill when nothing saved
|
|
1573
|
-
|
|
1574
|
-
//
|
|
1575
|
-
const nameEl = document.getElementById('current-channel-name');
|
|
1576
|
-
const cwdEl = document.getElementById('current-channel-cwd');
|
|
1577
|
-
if (nameEl) {
|
|
1578
|
-
const parts = String(daemonCwd).split('/').filter(Boolean);
|
|
1579
|
-
nameEl.textContent = parts[parts.length - 1] || 'project';
|
|
1580
|
-
}
|
|
1581
|
-
if (cwdEl) cwdEl.textContent = daemonCwd;
|
|
3097
|
+
daemonCwdForActive = daemonCwd;
|
|
3098
|
+
loadDashboardProjects(); // re-render sidebar now that we know which project is "active"
|
|
1582
3099
|
}
|
|
1583
3100
|
}).catch(() => { /* placeholder unchanged */ });
|
|
3101
|
+
|
|
3102
|
+
// Multi-project sidebar — bug A1 fix (May 5 2026). Pre-fix: sidebar showed
|
|
3103
|
+
// a single hardcoded "current" channel that was un-clickable; users hit it
|
|
3104
|
+
// expecting to switch projects. Post-fix: real /api/projects load, click
|
|
3105
|
+
// navigates to /threads?project=<slug> (where the actual thread surface
|
|
3106
|
+
// lives), pencil rename works inline, active state matches daemon cwd.
|
|
3107
|
+
function shortPathDash(p) {
|
|
3108
|
+
if (!p) return '';
|
|
3109
|
+
const home = (window.location && window.location.hostname) ? '~' : '~';
|
|
3110
|
+
if (p.length <= 32) return p;
|
|
3111
|
+
const parts = p.split('/').filter(Boolean);
|
|
3112
|
+
if (parts.length <= 2) return p;
|
|
3113
|
+
return '…/' + parts.slice(-2).join('/');
|
|
3114
|
+
}
|
|
3115
|
+
function escapeHtmlDash(s) {
|
|
3116
|
+
if (s == null) return '';
|
|
3117
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
3118
|
+
}
|
|
3119
|
+
function renderDashboardProjects(list) {
|
|
3120
|
+
const wrap = document.getElementById('channels-list');
|
|
3121
|
+
const countEl = document.getElementById('channels-count');
|
|
3122
|
+
if (!wrap) return;
|
|
3123
|
+
if (countEl) countEl.textContent = list.length;
|
|
3124
|
+
if (list.length === 0) {
|
|
3125
|
+
wrap.innerHTML = '<div class="pane-empty">No projects yet</div>';
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
wrap.innerHTML = list.map(p => {
|
|
3129
|
+
const isActive = (daemonCwdForActive && p.cwd === daemonCwdForActive) ? ' active' : '';
|
|
3130
|
+
const name = escapeHtmlDash(p.title || '(untitled)');
|
|
3131
|
+
const cwdHint = escapeHtmlDash(shortPathDash(p.cwd));
|
|
3132
|
+
// Pattern 4 — multi-agent presence stack. Up to 3 dots (most-recent
|
|
3133
|
+
// first, deduped). Empty span when no agents in the thread (older
|
|
3134
|
+
// pre-mirror projects). agentPresenceDotHTML lives in AGENT_BADGE_JS;
|
|
3135
|
+
// each dot is 6px, vendor-slot color, no letter.
|
|
3136
|
+
const agents = Array.isArray(p.recentAgents) ? p.recentAgents : (p.lastAgent ? [p.lastAgent] : []);
|
|
3137
|
+
const presenceStack = agents.length > 0
|
|
3138
|
+
? '<span class="presence-stack" title="Recent agents: ' + escapeHtmlDash(agents.join(', ')) + '">' +
|
|
3139
|
+
agents.map(a => agentPresenceDotHTML(a)).join('') +
|
|
3140
|
+
'</span>'
|
|
3141
|
+
: '<span class="presence-stack presence-stack-empty"></span>';
|
|
3142
|
+
return '<div class="channel' + isActive + '" data-slug="' + escapeHtmlDash(p.slug) + '" title="Open project thread">' +
|
|
3143
|
+
'<span class="channel-glyph">#</span>' +
|
|
3144
|
+
'<div style="flex:1;min-width:0">' +
|
|
3145
|
+
'<div class="channel-name">' + name + '</div>' +
|
|
3146
|
+
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
3147
|
+
'</div>' +
|
|
3148
|
+
presenceStack +
|
|
3149
|
+
'<button class="channel-rename" data-slug="' + escapeHtmlDash(p.slug) + '" title="Rename project" aria-label="Rename project"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg></button>' +
|
|
3150
|
+
'</div>';
|
|
3151
|
+
}).join('');
|
|
3152
|
+
wrap.querySelectorAll('.channel').forEach(el => {
|
|
3153
|
+
el.addEventListener('click', (ev) => {
|
|
3154
|
+
if (ev.target && ev.target.classList && ev.target.classList.contains('channel-rename')) return;
|
|
3155
|
+
const slug = el.getAttribute('data-slug');
|
|
3156
|
+
if (slug) window.location.href = '/threads?project=' + encodeURIComponent(slug);
|
|
3157
|
+
});
|
|
3158
|
+
});
|
|
3159
|
+
wrap.querySelectorAll('.channel-rename').forEach(btn => {
|
|
3160
|
+
btn.addEventListener('click', (ev) => {
|
|
3161
|
+
ev.stopPropagation();
|
|
3162
|
+
const slug = btn.getAttribute('data-slug');
|
|
3163
|
+
if (!slug) return;
|
|
3164
|
+
const channel = btn.closest('.channel');
|
|
3165
|
+
const project = list.find(p => p.slug === slug);
|
|
3166
|
+
const current = (project && project.title) || '';
|
|
3167
|
+
startInlineRename(channel, slug, current, () => loadDashboardProjects());
|
|
3168
|
+
});
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
3172
|
+
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
3173
|
+
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
3174
|
+
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
3175
|
+
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
3176
|
+
if (!channelEl) return;
|
|
3177
|
+
const nameEl = channelEl.querySelector('.channel-name');
|
|
3178
|
+
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
3179
|
+
nameEl.dataset.editing = '1';
|
|
3180
|
+
const originalText = nameEl.textContent;
|
|
3181
|
+
const input = document.createElement('input');
|
|
3182
|
+
input.type = 'text';
|
|
3183
|
+
input.value = currentTitle;
|
|
3184
|
+
input.maxLength = 100;
|
|
3185
|
+
input.className = 'channel-rename-input';
|
|
3186
|
+
nameEl.textContent = '';
|
|
3187
|
+
nameEl.appendChild(input);
|
|
3188
|
+
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
3189
|
+
let cleanedUp = false;
|
|
3190
|
+
function cleanup(restoreText) {
|
|
3191
|
+
if (cleanedUp) return;
|
|
3192
|
+
cleanedUp = true;
|
|
3193
|
+
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
3194
|
+
nameEl.textContent = restoreText;
|
|
3195
|
+
delete nameEl.dataset.editing;
|
|
3196
|
+
}
|
|
3197
|
+
function commit() {
|
|
3198
|
+
if (cleanedUp) return;
|
|
3199
|
+
const trimmed = input.value.trim();
|
|
3200
|
+
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
3201
|
+
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
3202
|
+
method: 'POST',
|
|
3203
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3204
|
+
body: JSON.stringify({ title: trimmed }),
|
|
3205
|
+
})
|
|
3206
|
+
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
3207
|
+
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
3208
|
+
.catch(() => {
|
|
3209
|
+
input.classList.remove('error');
|
|
3210
|
+
// Force reflow so animation re-fires
|
|
3211
|
+
void input.offsetWidth;
|
|
3212
|
+
input.classList.add('error');
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
input.addEventListener('keydown', (ev) => {
|
|
3216
|
+
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
3217
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
3218
|
+
});
|
|
3219
|
+
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
3220
|
+
}
|
|
3221
|
+
function loadDashboardProjects() {
|
|
3222
|
+
fetch('/api/projects')
|
|
3223
|
+
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
3224
|
+
.then(data => renderDashboardProjects(data.projects || []))
|
|
3225
|
+
.catch(() => { /* leave loading state */ });
|
|
3226
|
+
}
|
|
3227
|
+
loadDashboardProjects();
|
|
1584
3228
|
cwdInput.addEventListener('change', () => {
|
|
1585
3229
|
localStorage.setItem('codehere:cwd', cwdInput.value.trim());
|
|
1586
3230
|
});
|
|
@@ -1772,15 +3416,53 @@ ${SHARED_STYLES}
|
|
|
1772
3416
|
logEl.scrollTop = logEl.scrollHeight;
|
|
1773
3417
|
}
|
|
1774
3418
|
|
|
3419
|
+
// Status display labels: keep the underlying status string ('killed', 'error')
|
|
3420
|
+
// as the data-layer truth, but map to a calmer human label. May 6 2026 polish
|
|
3421
|
+
// per Open-Agents reference + claim_user_persona (non-technical readers
|
|
3422
|
+
// don't think of their work as "KILLED" — they think "I stopped it").
|
|
3423
|
+
// Pattern 6 · macro state → user-visible copy. Plain English; non-technical-
|
|
3424
|
+
// first per claim_user_persona HIGH. Each state's CSS class is the same
|
|
3425
|
+
// string with hyphens (e.g. status === 'rate_limit' → class="status-pill
|
|
3426
|
+
// rate-limit"); CSS classes can't contain underscores cleanly, so the
|
|
3427
|
+
// wire-format state has underscores and the class form has hyphens.
|
|
3428
|
+
// Per docs/design/AGENTIC_UX.md Pattern 6.
|
|
3429
|
+
var STATE_LABELS = {
|
|
3430
|
+
'thinking': 'Thinking',
|
|
3431
|
+
'running': 'Running',
|
|
3432
|
+
'running_buffered': 'Running (buffered)',
|
|
3433
|
+
'completed': 'Done',
|
|
3434
|
+
'awaiting': 'Waiting on you',
|
|
3435
|
+
'blocked': 'Blocked',
|
|
3436
|
+
'killed': 'Stopped',
|
|
3437
|
+
'crashed': 'Crashed',
|
|
3438
|
+
'error': 'Failed',
|
|
3439
|
+
'failed': 'Failed',
|
|
3440
|
+
'rate_limit': 'Rate-limited',
|
|
3441
|
+
'quota_exceeded': 'Quota exceeded',
|
|
3442
|
+
'credit_low': 'Credits low',
|
|
3443
|
+
'auth_invalid': 'Sign-in needed',
|
|
3444
|
+
'auth_missing': 'Sign-in needed',
|
|
3445
|
+
'model_unavailable':'Model unavailable'
|
|
3446
|
+
};
|
|
3447
|
+
function statusLabel(status) {
|
|
3448
|
+
if (!status) return 'Unknown';
|
|
3449
|
+
if (STATE_LABELS[status]) return STATE_LABELS[status];
|
|
3450
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
3451
|
+
}
|
|
3452
|
+
// Convert wire-format state ('rate_limit') to CSS class form ('rate-limit').
|
|
3453
|
+
function statusClass(status) {
|
|
3454
|
+
return String(status || '').replace(/_/g, '-');
|
|
3455
|
+
}
|
|
1775
3456
|
function updateStatus(pillEl, status) {
|
|
1776
|
-
pillEl.className = 'status-pill ' + status;
|
|
1777
|
-
pillEl.textContent = status;
|
|
3457
|
+
pillEl.className = 'status-pill ' + statusClass(status);
|
|
3458
|
+
pillEl.textContent = statusLabel(status);
|
|
1778
3459
|
}
|
|
1779
3460
|
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
3461
|
+
// Per-agent badge helpers (agentLabel, AGENT_SLOTS, AGENT_ICONS,
|
|
3462
|
+
// agentBadgeHTML, agentPresenceDotHTML) interpolated from AGENT_BADGE_JS
|
|
3463
|
+
// — single source of truth shared with THREAD_HTML and any other page
|
|
3464
|
+
// that needs vendor-attribution rendering. Per Pattern 10.
|
|
3465
|
+
${AGENT_BADGE_JS}
|
|
1784
3466
|
|
|
1785
3467
|
function attachSession(session) {
|
|
1786
3468
|
const card = document.createElement('div');
|
|
@@ -1795,17 +3477,20 @@ ${SHARED_STYLES}
|
|
|
1795
3477
|
'<div class="session-head">' +
|
|
1796
3478
|
'<div class="meta">' +
|
|
1797
3479
|
'<div class="prompt" title="' + escape(session.prompt) + '">' +
|
|
1798
|
-
'<span class="card-lights"><span class="r"></span><span class="y"></span><span class="g"></span></span>' +
|
|
1799
3480
|
escape(session.prompt) +
|
|
1800
3481
|
'<span class="chevron">▸</span>' +
|
|
1801
3482
|
'</div>' +
|
|
1802
|
-
'<div class="sub">' + liveDot +
|
|
3483
|
+
'<div class="sub">' + liveDot + agentBadgeHTML(session.agent) + ' ' + escape(agentLabel(session.agent)) +
|
|
3484
|
+
(session.cwd ? ' · <span class="sub-cwd" title="' + escape(session.cwd) + '">' + escape(cwdTail(session.cwd)) + '</span>' : '') +
|
|
3485
|
+
' · ' + new Date(session.startedAt).toLocaleTimeString() +
|
|
1803
3486
|
'<span class="safety-pill" style="display:none">' +
|
|
1804
3487
|
'<span class="shield">🛡</span> ' +
|
|
1805
3488
|
'<span class="safe">0</span> safe' +
|
|
1806
3489
|
'<span class="blocked-portion" style="display:none"> · <span class="blocked">0</span> blocked</span>' +
|
|
1807
3490
|
'</span>' +
|
|
1808
3491
|
'</div>' +
|
|
3492
|
+
'<div class="tool-progress-chip" data-state="hidden"></div>' +
|
|
3493
|
+
'<div class="micro-activity"></div>' +
|
|
1809
3494
|
'<div class="steps-mini"></div>' +
|
|
1810
3495
|
'</div>' +
|
|
1811
3496
|
'<div class="session-controls">' +
|
|
@@ -1813,7 +3498,7 @@ ${SHARED_STYLES}
|
|
|
1813
3498
|
'<button class="view-thread active" data-view="thread">Thread</button>' +
|
|
1814
3499
|
'<button class="view-raw" data-view="raw">Log</button>' +
|
|
1815
3500
|
'</div>' +
|
|
1816
|
-
'<span class="status-pill ' + session.status + runningClass + '">' + session.status + '</span>' +
|
|
3501
|
+
'<span class="status-pill ' + session.status + runningClass + '">' + statusLabel(session.status) + '</span>' +
|
|
1817
3502
|
'<button class="kill">Kill</button>' +
|
|
1818
3503
|
'</div>' +
|
|
1819
3504
|
'</div>' +
|
|
@@ -1824,6 +3509,23 @@ ${SHARED_STYLES}
|
|
|
1824
3509
|
// so on completed sessions the Final Report appeared above the user's
|
|
1825
3510
|
// seed prompt, breaking chronological order in thread view.
|
|
1826
3511
|
'<div class="thread-messages"></div>' +
|
|
3512
|
+
// P18 slice 4 — permission-gate stack. Empty container; panels
|
|
3513
|
+
// get inserted by the per-session blocks poll when the per-tool
|
|
3514
|
+
// hook fires for this session. Each block becomes one panel with
|
|
3515
|
+
// an "Approve in this folder" button.
|
|
3516
|
+
'<div class="permission-gates"></div>' +
|
|
3517
|
+
// v0.6 M3 — PM narrator block. Hidden until first narration
|
|
3518
|
+
// arrives; renders one paragraph plain-English of what the
|
|
3519
|
+
// agent is doing right now. Per claim_user_persona HIGH:
|
|
3520
|
+
// non-technical users read this, not the tool calls.
|
|
3521
|
+
// Per feedback_overconfidence_guard.md: framed as "narrator"
|
|
3522
|
+
// not authoritative truth — narration is an LLM call, may
|
|
3523
|
+
// hallucinate.
|
|
3524
|
+
'<div class="narrator-block" style="display:none">' +
|
|
3525
|
+
'<div class="narrator-label">narrator</div>' +
|
|
3526
|
+
'<div class="narrator-text"></div>' +
|
|
3527
|
+
'<div class="narrator-meta"></div>' +
|
|
3528
|
+
'</div>' +
|
|
1827
3529
|
'<div class="final-report" style="display:none"></div>' +
|
|
1828
3530
|
// Iter 12 — reply surface now functional; replaced the prior dead
|
|
1829
3531
|
// placeholder. Active for adapters with canAppendMessage=true
|
|
@@ -1835,16 +3537,291 @@ ${SHARED_STYLES}
|
|
|
1835
3537
|
'<div class="reply-status" style="display:none"></div>' +
|
|
1836
3538
|
'</div>' +
|
|
1837
3539
|
'<div class="event-log"></div>' +
|
|
3540
|
+
'</div>' +
|
|
3541
|
+
// Pattern 3 — session footer. Hidden until populated (data-empty=true);
|
|
3542
|
+
// renderToolProgress flips it to data-empty=false once steps appear.
|
|
3543
|
+
'<div class="session-foot" data-empty="true">' +
|
|
3544
|
+
'<span class="sf-item sf-elapsed"><b>0s</b><span>elapsed</span></span>' +
|
|
3545
|
+
'<span class="sf-sep">·</span>' +
|
|
3546
|
+
'<span class="sf-item sf-tools"><b>0</b><span>tools</span></span>' +
|
|
3547
|
+
'<span class="sf-sep">·</span>' +
|
|
3548
|
+
'<span class="sf-item sf-files"><b>0</b><span>files</span></span>' +
|
|
1838
3549
|
'</div>';
|
|
1839
3550
|
|
|
1840
3551
|
sessionsEl.prepend(card);
|
|
1841
3552
|
emptyEl.style.display = 'none';
|
|
3553
|
+
// v0.6 S2 — first session attached means user has done step 1+2;
|
|
3554
|
+
// dismiss the onboarding card. They can re-open by clearing local
|
|
3555
|
+
// storage, but for the typical flow this is "I'm using it now".
|
|
3556
|
+
dismissOnboarding();
|
|
1842
3557
|
|
|
1843
3558
|
const logEl = card.querySelector('.event-log');
|
|
1844
3559
|
const pillEl = card.querySelector('.status-pill');
|
|
1845
3560
|
const killBtn = card.querySelector('.kill');
|
|
1846
3561
|
const headEl = card.querySelector('.session-head');
|
|
1847
3562
|
const stepsEl = card.querySelector('.steps-mini');
|
|
3563
|
+
const microActivityEl = card.querySelector('.micro-activity');
|
|
3564
|
+
const toolProgressEl = card.querySelector('.tool-progress-chip');
|
|
3565
|
+
// Pattern 1 — tool-progress live chip. Renders "12 tools | 3/4 ●●●○ | Ns".
|
|
3566
|
+
// Sources from the same codehere:steps synthetic event renderSteps reads.
|
|
3567
|
+
function fmtElapsed(ms) {
|
|
3568
|
+
var s = Math.max(0, Math.floor(ms / 1000));
|
|
3569
|
+
if (s < 60) return s + 's';
|
|
3570
|
+
var m = Math.floor(s / 60);
|
|
3571
|
+
var rem = s % 60;
|
|
3572
|
+
if (m < 60) return rem ? m + 'm ' + rem + 's' : m + 'm';
|
|
3573
|
+
var h = Math.floor(m / 60);
|
|
3574
|
+
return h + 'h ' + (m % 60) + 'm';
|
|
3575
|
+
}
|
|
3576
|
+
function renderToolProgress(steps) {
|
|
3577
|
+
if (!toolProgressEl) return;
|
|
3578
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3579
|
+
toolProgressEl.dataset.state = 'hidden';
|
|
3580
|
+
toolProgressEl.innerHTML = '';
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
// Compute counts + state.
|
|
3584
|
+
var done = 0, active = 0, failed = 0, pending = 0;
|
|
3585
|
+
for (var i = 0; i < steps.length; i++) {
|
|
3586
|
+
var st = String(steps[i].state || '');
|
|
3587
|
+
if (st === 'done') done++;
|
|
3588
|
+
else if (st === 'active') active++;
|
|
3589
|
+
else if (st === 'failed') failed++;
|
|
3590
|
+
else pending++;
|
|
3591
|
+
}
|
|
3592
|
+
var total = steps.length;
|
|
3593
|
+
var chipState;
|
|
3594
|
+
if (failed > 0) chipState = 'failed';
|
|
3595
|
+
else if (session.status !== 'running') chipState = (done === total ? 'complete' : 'paused');
|
|
3596
|
+
else if (active > 0) chipState = 'running';
|
|
3597
|
+
else chipState = 'paused';
|
|
3598
|
+
|
|
3599
|
+
// Dot pattern — last 6 steps, oldest first. ● = done, ◐ = active,
|
|
3600
|
+
// ○ = pending, ✗ = failed. Cap at 6 so the chip doesn't grow.
|
|
3601
|
+
var tail = steps.slice(-6);
|
|
3602
|
+
var dots = tail.map(function(s) {
|
|
3603
|
+
var st = String(s.state || '');
|
|
3604
|
+
if (st === 'done') return '●';
|
|
3605
|
+
if (st === 'active') return '◐';
|
|
3606
|
+
if (st === 'failed') return '✗';
|
|
3607
|
+
return '○';
|
|
3608
|
+
}).join('');
|
|
3609
|
+
|
|
3610
|
+
var elapsed = fmtElapsed(Date.now() - session.startedAt);
|
|
3611
|
+
toolProgressEl.dataset.state = chipState;
|
|
3612
|
+
toolProgressEl.innerHTML =
|
|
3613
|
+
'<span>' + total + ' tool' + (total === 1 ? '' : 's') + '</span>' +
|
|
3614
|
+
'<span class="tp-sep">|</span>' +
|
|
3615
|
+
'<span>' + done + '/' + total + ' <span class="tp-dots">' + dots + '</span></span>' +
|
|
3616
|
+
'<span class="tp-sep">|</span>' +
|
|
3617
|
+
'<span class="tp-elapsed">' + elapsed + '</span>';
|
|
3618
|
+
|
|
3619
|
+
// Pattern 3 — keep the footer in sync with the same step data the
|
|
3620
|
+
// chip reads. Cheaper than a separate event subscription and stays
|
|
3621
|
+
// structurally consistent with the live counts above.
|
|
3622
|
+
renderSessionFoot(steps);
|
|
3623
|
+
}
|
|
3624
|
+
// Pattern 3 — session-foot: elapsed · tools · files. Reveals on first
|
|
3625
|
+
// step; updates counts on every renderSteps call. Read-only — no
|
|
3626
|
+
// click affordance (rec'y / file-list belong elsewhere).
|
|
3627
|
+
var sessionFootEl = card.querySelector('.session-foot');
|
|
3628
|
+
function renderSessionFoot(steps) {
|
|
3629
|
+
if (!sessionFootEl) return;
|
|
3630
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3631
|
+
sessionFootEl.dataset.empty = 'true';
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
sessionFootEl.dataset.empty = 'false';
|
|
3635
|
+
var elapsed = session.completedAt
|
|
3636
|
+
? fmtElapsed(session.completedAt - session.startedAt)
|
|
3637
|
+
: fmtElapsed(Date.now() - session.startedAt);
|
|
3638
|
+
var elapsedB = sessionFootEl.querySelector('.sf-elapsed b');
|
|
3639
|
+
var toolsB = sessionFootEl.querySelector('.sf-tools b');
|
|
3640
|
+
var filesB = sessionFootEl.querySelector('.sf-files b');
|
|
3641
|
+
if (elapsedB) elapsedB.textContent = elapsed;
|
|
3642
|
+
if (toolsB) toolsB.textContent = String(steps.length);
|
|
3643
|
+
if (filesB) filesB.textContent = String(countTouchedFiles(steps));
|
|
3644
|
+
}
|
|
3645
|
+
// Tick elapsed display every second while running so the chip feels live.
|
|
3646
|
+
var elapsedTicker = null;
|
|
3647
|
+
function startElapsedTick() {
|
|
3648
|
+
if (elapsedTicker) return;
|
|
3649
|
+
elapsedTicker = setInterval(function() {
|
|
3650
|
+
if (!toolProgressEl || toolProgressEl.dataset.state === 'hidden') return;
|
|
3651
|
+
var elEl = toolProgressEl.querySelector('.tp-elapsed');
|
|
3652
|
+
if (elEl) elEl.textContent = fmtElapsed(Date.now() - session.startedAt);
|
|
3653
|
+
}, 1000);
|
|
3654
|
+
}
|
|
3655
|
+
function stopElapsedTick() {
|
|
3656
|
+
if (elapsedTicker) { clearInterval(elapsedTicker); elapsedTicker = null; }
|
|
3657
|
+
}
|
|
3658
|
+
if (session.status === 'running') startElapsedTick();
|
|
3659
|
+
|
|
3660
|
+
// v0.6 M3 — PM narrator polling. Backend generates a one-paragraph
|
|
3661
|
+
// mid-task summary every 30s while the session runs; frontend polls
|
|
3662
|
+
// /api/task/:id/narration and renders it in the narrator-block.
|
|
3663
|
+
// Per claim_user_persona: non-technical users read narration, not
|
|
3664
|
+
// tool calls. Per overconfidence-guard: framed as "narrator", not
|
|
3665
|
+
// authoritative truth.
|
|
3666
|
+
var narrTicker = null;
|
|
3667
|
+
var lastNarrAt = 0;
|
|
3668
|
+
function pollNarration() {
|
|
3669
|
+
fetch('/api/task/' + session.sessionId + '/narration')
|
|
3670
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3671
|
+
.then(function(d) {
|
|
3672
|
+
if (!d || !d.narration) return;
|
|
3673
|
+
var n = d.narration;
|
|
3674
|
+
if (n.generatedAt <= lastNarrAt) return; // unchanged
|
|
3675
|
+
lastNarrAt = n.generatedAt;
|
|
3676
|
+
var block = card.querySelector('.narrator-block');
|
|
3677
|
+
if (!block) return;
|
|
3678
|
+
var textEl = block.querySelector('.narrator-text');
|
|
3679
|
+
var metaEl = block.querySelector('.narrator-meta');
|
|
3680
|
+
if (textEl) textEl.textContent = n.summary;
|
|
3681
|
+
if (metaEl) {
|
|
3682
|
+
var ago = Math.max(0, Math.floor((Date.now() - n.generatedAt) / 1000));
|
|
3683
|
+
metaEl.textContent = 'after ' + n.stepCountAtSnapshot + ' steps · ' + ago + 's ago · may be inaccurate';
|
|
3684
|
+
}
|
|
3685
|
+
block.style.display = 'block';
|
|
3686
|
+
})
|
|
3687
|
+
.catch(function() { /* skip */ });
|
|
3688
|
+
}
|
|
3689
|
+
function startNarrationTick() {
|
|
3690
|
+
if (narrTicker) return;
|
|
3691
|
+
pollNarration(); // immediate
|
|
3692
|
+
narrTicker = setInterval(pollNarration, 30000);
|
|
3693
|
+
}
|
|
3694
|
+
function stopNarrationTick() {
|
|
3695
|
+
if (narrTicker) { clearInterval(narrTicker); narrTicker = null; }
|
|
3696
|
+
}
|
|
3697
|
+
if (session.status === 'running') startNarrationTick();
|
|
3698
|
+
|
|
3699
|
+
// P18 slice 4 — poll /api/task/:id/blocks for hook-blocks against
|
|
3700
|
+
// this session. Render one .permission-gate panel per unique block.
|
|
3701
|
+
// De-dup by hash of category + matched + timestamp.
|
|
3702
|
+
var blockTicker = null;
|
|
3703
|
+
var seenBlockKeys = Object.create(null);
|
|
3704
|
+
function escapeHtmlInline(s) {
|
|
3705
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
|
3706
|
+
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
function renderBlockGate(block) {
|
|
3710
|
+
// The block entry shape comes from the audit log; toolInput may be
|
|
3711
|
+
// a JSON string with {agent, command, allowedByPermissions, ...}
|
|
3712
|
+
var key = (block.timestamp || '') + '|' + (block.toolName || '') + '|' + (block.findings || 0);
|
|
3713
|
+
if (seenBlockKeys[key]) return;
|
|
3714
|
+
seenBlockKeys[key] = true;
|
|
3715
|
+
var gateContainer = card.querySelector('.permission-gates');
|
|
3716
|
+
if (!gateContainer) return;
|
|
3717
|
+
// Pull a useful matched-text + cwd out of the block. The audit
|
|
3718
|
+
// entry surfaces them in toolInput JSON shape.
|
|
3719
|
+
var pattern = '';
|
|
3720
|
+
var cwd = '';
|
|
3721
|
+
try {
|
|
3722
|
+
var parsedInput = typeof block.toolInput === 'string' ? JSON.parse(block.toolInput) : block.toolInput;
|
|
3723
|
+
if (parsedInput && typeof parsedInput === 'object') {
|
|
3724
|
+
// command (Bash) or file_path (Edit/Write) — best-effort
|
|
3725
|
+
pattern = String(parsedInput.command || parsedInput.file_path || '');
|
|
3726
|
+
cwd = String(parsedInput.cwd || '');
|
|
3727
|
+
}
|
|
3728
|
+
} catch (_) { /* not JSON */ }
|
|
3729
|
+
var truncatedPattern = pattern.length > 200 ? pattern.slice(0, 200) + '…' : pattern;
|
|
3730
|
+
var gate = document.createElement('div');
|
|
3731
|
+
gate.className = 'permission-gate';
|
|
3732
|
+
gate.innerHTML =
|
|
3733
|
+
'<div class="pg-label">codehere noticed a pattern</div>' +
|
|
3734
|
+
'<div class="pg-title">Is this expected here? You can approve it for this folder so future runs do not stop on the same pattern.</div>' +
|
|
3735
|
+
'<div class="pg-detail">' + escapeHtmlInline(truncatedPattern || '(no command captured)') + '</div>' +
|
|
3736
|
+
'<div class="pg-actions">' +
|
|
3737
|
+
'<button type="button" class="pg-approve">Approve in this folder</button>' +
|
|
3738
|
+
'<button type="button" class="pg-keep">Keep blocking</button>' +
|
|
3739
|
+
'</div>' +
|
|
3740
|
+
'<div class="pg-status">Approved · added to .codehere/permissions.yaml</div>';
|
|
3741
|
+
gateContainer.appendChild(gate);
|
|
3742
|
+
gate.querySelector('.pg-keep').addEventListener('click', function() {
|
|
3743
|
+
gate.remove();
|
|
3744
|
+
});
|
|
3745
|
+
gate.querySelector('.pg-approve').addEventListener('click', function() {
|
|
3746
|
+
var btn = gate.querySelector('.pg-approve');
|
|
3747
|
+
btn.disabled = true;
|
|
3748
|
+
btn.textContent = '…';
|
|
3749
|
+
// Approve the EXACT pattern that triggered the block; reason
|
|
3750
|
+
// captures the block's matched-finding context for the audit.
|
|
3751
|
+
// Fall back to a substring of the command if no finding text.
|
|
3752
|
+
var approvePattern = pattern;
|
|
3753
|
+
var reasonSuffix = block.findings ? (block.findings + ' finding' + (block.findings === 1 ? '' : 's') + ' caught at this site') : 'caught by hook';
|
|
3754
|
+
var reason = 'Approved via dashboard panel · ' + reasonSuffix;
|
|
3755
|
+
if (!cwd) {
|
|
3756
|
+
alert('Cannot approve: no cwd captured in the audit entry. Use codehere permission allow on the CLI.');
|
|
3757
|
+
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
fetch('/api/permissions', {
|
|
3761
|
+
method: 'POST',
|
|
3762
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3763
|
+
body: JSON.stringify({ cwd: cwd, pattern: approvePattern, reason: reason }),
|
|
3764
|
+
})
|
|
3765
|
+
.then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
|
|
3766
|
+
.then(function() {
|
|
3767
|
+
gate.classList.add('approved');
|
|
3768
|
+
})
|
|
3769
|
+
.catch(function() {
|
|
3770
|
+
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3771
|
+
alert('Approve failed — try the CLI: codehere permission allow ' + JSON.stringify(approvePattern));
|
|
3772
|
+
});
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
function pollBlocks() {
|
|
3776
|
+
fetch('/api/task/' + session.sessionId + '/blocks')
|
|
3777
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3778
|
+
.then(function(d) {
|
|
3779
|
+
if (!d || !Array.isArray(d.blocks)) return;
|
|
3780
|
+
for (var i = 0; i < d.blocks.length; i++) renderBlockGate(d.blocks[i]);
|
|
3781
|
+
})
|
|
3782
|
+
.catch(function() { /* skip */ });
|
|
3783
|
+
}
|
|
3784
|
+
function startBlockTick() {
|
|
3785
|
+
if (blockTicker) return;
|
|
3786
|
+
pollBlocks();
|
|
3787
|
+
blockTicker = setInterval(pollBlocks, 30000);
|
|
3788
|
+
}
|
|
3789
|
+
function stopBlockTick() {
|
|
3790
|
+
if (blockTicker) { clearInterval(blockTicker); blockTicker = null; }
|
|
3791
|
+
}
|
|
3792
|
+
if (session.status === 'running') startBlockTick();
|
|
3793
|
+
|
|
3794
|
+
// Pattern 13 — start whimsy rotator only while status === 'running' and
|
|
3795
|
+
// there's no active tool step. stopWhimsy() runs on exit (or on every
|
|
3796
|
+
// active-step swap; we restart it when the active step ends).
|
|
3797
|
+
var stopWhimsy = null;
|
|
3798
|
+
var lastMode = ''; // 'whimsy' | 'tool' | ''
|
|
3799
|
+
function setMicroState(mode, label) {
|
|
3800
|
+
if (!microActivityEl) return;
|
|
3801
|
+
if (mode === '') {
|
|
3802
|
+
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3803
|
+
microActivityEl.classList.remove('live', 'tool');
|
|
3804
|
+
microActivityEl.textContent = '';
|
|
3805
|
+
lastMode = '';
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (mode === 'tool') {
|
|
3809
|
+
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3810
|
+
microActivityEl.classList.add('live', 'tool');
|
|
3811
|
+
microActivityEl.textContent = label || 'Working…';
|
|
3812
|
+
lastMode = 'tool';
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
// mode === 'whimsy'
|
|
3816
|
+
microActivityEl.classList.add('live');
|
|
3817
|
+
microActivityEl.classList.remove('tool');
|
|
3818
|
+
if (lastMode !== 'whimsy') {
|
|
3819
|
+
if (stopWhimsy) stopWhimsy();
|
|
3820
|
+
stopWhimsy = startWhimsyRotator(microActivityEl);
|
|
3821
|
+
lastMode = 'whimsy';
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
if (session.status === 'running') setMicroState('whimsy');
|
|
1848
3825
|
const finalReportEl = card.querySelector('.final-report');
|
|
1849
3826
|
const threadEl = card.querySelector('.thread-messages');
|
|
1850
3827
|
const bodyEl = card.querySelector('.session-body');
|
|
@@ -2077,8 +4054,12 @@ ${SHARED_STYLES}
|
|
|
2077
4054
|
// steps with pass/active/fail glyphs; hide the rest behind "+ N more" so
|
|
2078
4055
|
// the card stays compact even on a 20-step task.
|
|
2079
4056
|
function renderSteps(steps) {
|
|
4057
|
+
// Pattern 1 — update the tool-progress chip on every step snapshot.
|
|
4058
|
+
renderToolProgress(steps);
|
|
2080
4059
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
2081
4060
|
stepsEl.innerHTML = '';
|
|
4061
|
+
// Pattern 13: no steps + still running → keep whimsy rotation alive.
|
|
4062
|
+
if (session.status === 'running' && lastMode !== 'whimsy') setMicroState('whimsy');
|
|
2082
4063
|
return;
|
|
2083
4064
|
}
|
|
2084
4065
|
const tail = steps.slice(-3);
|
|
@@ -2088,9 +4069,60 @@ ${SHARED_STYLES}
|
|
|
2088
4069
|
tail.map(s => {
|
|
2089
4070
|
const st = String(s.state || 'pending');
|
|
2090
4071
|
const g = glyph[st] || '·';
|
|
2091
|
-
|
|
4072
|
+
// Pattern 2 — derive tool kind + split label so the verb-color
|
|
4073
|
+
// semantic CSS applies (read/grep muted, write/edit green, bash
|
|
4074
|
+
// peach). Falls back to a single .verb span when label is one word.
|
|
4075
|
+
// Pattern 14 — smart-truncate the target per tool kind so the
|
|
4076
|
+
// meaningful chunk survives the trace's narrow column.
|
|
4077
|
+
const kind = inferToolKind(s.label);
|
|
4078
|
+
const parts = splitStepLabel(String(s.label || ''));
|
|
4079
|
+
const truncatedTarget = truncateTarget(kind, parts.target);
|
|
4080
|
+
const labelHtml = truncatedTarget
|
|
4081
|
+
? '<span class="verb">' + escape(parts.verb) + '</span><span class="target">' + escape(truncatedTarget) + '</span>'
|
|
4082
|
+
: '<span class="verb">' + escape(parts.verb) + '</span>';
|
|
4083
|
+
// P22 / Track B #8 — bash output preview. Render last 4
|
|
4084
|
+
// lines of step.output under the step header. Only shows
|
|
4085
|
+
// for done/failed steps (active steps don't have output yet).
|
|
4086
|
+
// Note: split-on-newline uses fromCharCode(10) to dodge the
|
|
4087
|
+
// outer TS template literal eating the backslash-n.
|
|
4088
|
+
var outputHtml = '';
|
|
4089
|
+
if (s.output && (st === 'done' || st === 'failed')) {
|
|
4090
|
+
var lines = String(s.output).split(String.fromCharCode(10));
|
|
4091
|
+
var tailLines = lines.slice(-4);
|
|
4092
|
+
outputHtml = '<div class="step-output">' +
|
|
4093
|
+
tailLines.map(function(ln) { return escape(ln); }).join('<br>') +
|
|
4094
|
+
'</div>';
|
|
4095
|
+
}
|
|
4096
|
+
return '<div class="step ' + st + '" data-tool="' + escape(kind) + '"><span class="ico">' + g + '</span>' + labelHtml + outputHtml + '</div>';
|
|
2092
4097
|
}).join('') +
|
|
2093
4098
|
(more > 0 ? '<div class="more">+ ' + more + ' earlier</div>' : '');
|
|
4099
|
+
|
|
4100
|
+
// Pattern 13: tool-prefixed verb takes over while a step is active.
|
|
4101
|
+
// Most-recent active step's label becomes the micro-activity text;
|
|
4102
|
+
// when no step is active we fall back to whimsy rotation.
|
|
4103
|
+
// Pattern 17: if the active step's elapsed > 5s, append a "· Ns"
|
|
4104
|
+
// suffix so the user can see how long this single tool has been
|
|
4105
|
+
// running. Simple slice — doesn't yet upgrade to the bar / ESC hint
|
|
4106
|
+
// at 15-30s thresholds; that's deferred until the affordance
|
|
4107
|
+
// demands it in real dogfood.
|
|
4108
|
+
const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
|
|
4109
|
+
if (active && session.status === 'running') {
|
|
4110
|
+
let label = String(active.label || 'Working on it…');
|
|
4111
|
+
const startedAt = Number(active.startedAt || 0);
|
|
4112
|
+
if (startedAt > 0) {
|
|
4113
|
+
const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
|
|
4114
|
+
if (elapsedSec >= 5) {
|
|
4115
|
+
// Use fmtElapsed for consistency with the chip's elapsed format.
|
|
4116
|
+
label += ' · ' + fmtElapsed(Date.now() - startedAt);
|
|
4117
|
+
if (elapsedSec >= 30) label += ' · ESC to cancel';
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
setMicroState('tool', label);
|
|
4121
|
+
} else if (session.status === 'running') {
|
|
4122
|
+
setMicroState('whimsy');
|
|
4123
|
+
} else {
|
|
4124
|
+
setMicroState('');
|
|
4125
|
+
}
|
|
2094
4126
|
}
|
|
2095
4127
|
|
|
2096
4128
|
// Layer 3: render the final report block when the task completes.
|
|
@@ -2138,16 +4170,29 @@ ${SHARED_STYLES}
|
|
|
2138
4170
|
html += '<div class="fr-summary fr-summary-pending"><p>Final Report unavailable for this session. (Most often this means the daemon restarted — reports aren\\'t persisted yet. Re-spawn to get a fresh one.)</p></div>';
|
|
2139
4171
|
}
|
|
2140
4172
|
if (files.length > 0) {
|
|
4173
|
+
// Pattern 7 — file chip with mutation tint, trust grade letter,
|
|
4174
|
+
// diff stats. Mutation kind derives from added/removed counts so
|
|
4175
|
+
// the file-icon color signals "create / edit / delete" at glance.
|
|
4176
|
+
const FILE_ICON_SVG = '<svg class="file-icon" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M3 1 L9 1 L11.5 3.5 L11.5 13 L3 13 Z"/><path d="M9 1 L9 3.5 L11.5 3.5"/></svg>';
|
|
4177
|
+
const trustGrade = (n) => n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F';
|
|
2141
4178
|
html += '<div class="fr-files">' + files.map(f => {
|
|
2142
|
-
const
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
4179
|
+
const added = Number(f.added || 0);
|
|
4180
|
+
const removed = Number(f.removed || 0);
|
|
4181
|
+
let mutation;
|
|
4182
|
+
if (added > 0 && removed === 0) mutation = 'write';
|
|
4183
|
+
else if (added === 0 && removed > 0) mutation = 'delete';
|
|
4184
|
+
else mutation = 'edit';
|
|
4185
|
+
const lines = '<span class="add">+' + added + '</span> <span class="rem">−' + removed + '</span>';
|
|
4186
|
+
// Pattern 11 — file chip uses the standalone trust-badge helper
|
|
4187
|
+
// instead of inline class-picking. Single source of truth for
|
|
4188
|
+
// grade-cutoff + color-split. (Note: this CORRECTS the prior
|
|
4189
|
+
// 60-cutoff which put D-grade trust in red; spec puts D in
|
|
4190
|
+
// warn-yellow.)
|
|
4191
|
+
return '<div class="fr-file" data-mutation="' + escape(mutation) + '">' +
|
|
4192
|
+
FILE_ICON_SVG +
|
|
2148
4193
|
'<span class="path" title="' + escape(String(f.path)) + '">' + escape(String(f.path)) + '</span>' +
|
|
2149
4194
|
'<span class="lines">' + lines + '</span>' +
|
|
2150
|
-
|
|
4195
|
+
trustBadgeHTML(typeof f.trust === 'number' ? f.trust : null) +
|
|
2151
4196
|
'</div>';
|
|
2152
4197
|
}).join('') + '</div>';
|
|
2153
4198
|
} else {
|
|
@@ -2179,7 +4224,10 @@ ${SHARED_STYLES}
|
|
|
2179
4224
|
// Persisted sessions have no live event stream and no subprocess to kill.
|
|
2180
4225
|
if (isPersisted) {
|
|
2181
4226
|
killBtn.style.display = 'none';
|
|
2182
|
-
|
|
4227
|
+
// Use .persisted (visible, faded). Pre-fix this added .observed
|
|
4228
|
+
// which then collided with the detected-process hide rule from
|
|
4229
|
+
// claim_user_persona work — your own past sessions disappeared.
|
|
4230
|
+
card.classList.add('persisted');
|
|
2183
4231
|
return;
|
|
2184
4232
|
}
|
|
2185
4233
|
|
|
@@ -2192,11 +4240,57 @@ ${SHARED_STYLES}
|
|
|
2192
4240
|
if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
|
|
2193
4241
|
if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
|
|
2194
4242
|
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
4243
|
+
// Pattern 6 · codehere:error events carry a structured code that maps
|
|
4244
|
+
// 1:1 to a macro state. rate_limit / quota_exceeded / credit_low /
|
|
4245
|
+
// auth_invalid / model_unavailable each get their own pill copy +
|
|
4246
|
+
// color so users can see WHY the agent stopped, not just THAT it did.
|
|
4247
|
+
// Falls back to 'error' (red "Failed") for unknown codes.
|
|
2198
4248
|
if (event.type === 'codehere:error') {
|
|
2199
|
-
|
|
4249
|
+
var code = event.data && event.data.code;
|
|
4250
|
+
var state = code && STATE_LABELS[code] ? code : 'error';
|
|
4251
|
+
updateStatus(pillEl, state);
|
|
4252
|
+
// Pattern 9 — surface a recovery affordance below the session
|
|
4253
|
+
// header for known failure codes. Idempotent: if already shown,
|
|
4254
|
+
// skip (sessions can emit codehere:error multiple times).
|
|
4255
|
+
if (code && !card.querySelector('.recovery-affordance')) {
|
|
4256
|
+
var nextAgent = pickFailoverTarget(session.agent);
|
|
4257
|
+
var built = buildRecoveryAffordance(code, session.agent, nextAgent);
|
|
4258
|
+
// Insert between session-head and session-body so the affordance
|
|
4259
|
+
// sits at the top of the card body (most-prominent position).
|
|
4260
|
+
var bodyEl = card.querySelector('.session-body');
|
|
4261
|
+
if (bodyEl) bodyEl.parentNode.insertBefore(built.el, bodyEl);
|
|
4262
|
+
else card.appendChild(built.el);
|
|
4263
|
+
built.el.querySelector('.ra-secondary').addEventListener('click', function() {
|
|
4264
|
+
built.el.remove();
|
|
4265
|
+
});
|
|
4266
|
+
built.el.querySelector('.ra-primary').addEventListener('click', async function() {
|
|
4267
|
+
if (!built.copy.canFailover) {
|
|
4268
|
+
// Auth/setup path — open the agents pane (already in DOM)
|
|
4269
|
+
// instead of spawning a new session with the same broken auth.
|
|
4270
|
+
built.el.remove();
|
|
4271
|
+
return;
|
|
4272
|
+
}
|
|
4273
|
+
var primary = built.el.querySelector('.ra-primary');
|
|
4274
|
+
primary.disabled = true;
|
|
4275
|
+
try {
|
|
4276
|
+
await fetch('/api/task', {
|
|
4277
|
+
method: 'POST',
|
|
4278
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4279
|
+
body: JSON.stringify({
|
|
4280
|
+
prompt: session.prompt,
|
|
4281
|
+
agent: nextAgent,
|
|
4282
|
+
// Reuse the current intake cwd — the failover spawns
|
|
4283
|
+
// against whichever repo the user is currently scoped
|
|
4284
|
+
// to. Acceptable trade-off vs. a per-session lookup.
|
|
4285
|
+
cwd: (cwdInput && cwdInput.value && cwdInput.value.trim()) || ''
|
|
4286
|
+
})
|
|
4287
|
+
});
|
|
4288
|
+
built.el.remove();
|
|
4289
|
+
} catch {
|
|
4290
|
+
primary.disabled = false;
|
|
4291
|
+
}
|
|
4292
|
+
});
|
|
4293
|
+
}
|
|
2200
4294
|
// fall through — renderEvent renders the red banner in the log
|
|
2201
4295
|
}
|
|
2202
4296
|
|
|
@@ -2210,22 +4304,75 @@ ${SHARED_STYLES}
|
|
|
2210
4304
|
killBtn.disabled = true; killBtn.style.opacity = '0.4';
|
|
2211
4305
|
const dot = card.querySelector('.sub .live-dot');
|
|
2212
4306
|
if (dot) dot.remove();
|
|
4307
|
+
setMicroState(''); // Pattern 13 — stop rotator on session exit
|
|
4308
|
+
stopElapsedTick(); // Pattern 1 — stop the chip's elapsed ticker
|
|
4309
|
+
stopNarrationTick(); // v0.6 M3 — stop the narrator poll on exit
|
|
4310
|
+
// Final narration fetch — backend may emit one last summary
|
|
4311
|
+
// after the exit event; give it a beat then poll once more.
|
|
4312
|
+
setTimeout(pollNarration, 2000);
|
|
4313
|
+
stopBlockTick(); // P18 slice 4 — stop the per-session block poll
|
|
4314
|
+
// Final blocks fetch — agent may have run one last hook-blocked
|
|
4315
|
+
// tool call right before exit; surface it.
|
|
4316
|
+
setTimeout(pollBlocks, 2000);
|
|
4317
|
+
// Final chip render with the terminal status so it shows the last
|
|
4318
|
+
// dot pattern + final elapsed value in the chip's complete/failed state.
|
|
4319
|
+
if (Array.isArray(session.steps)) renderToolProgress(session.steps);
|
|
2213
4320
|
es.close();
|
|
2214
4321
|
}
|
|
2215
4322
|
if (event.type === 'error') {
|
|
4323
|
+
setMicroState(''); // Pattern 13
|
|
2216
4324
|
updateStatus(pillEl, 'error');
|
|
2217
4325
|
}
|
|
2218
4326
|
} catch { /* skip */ }
|
|
2219
4327
|
};
|
|
2220
4328
|
es.onerror = () => { /* server may have closed after exit; fine */ };
|
|
2221
4329
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
4330
|
+
// Pattern 19 — interrupt affordance with 2-press confirm + ESC shortcut.
|
|
4331
|
+
// First press flips the button to a "press again to confirm" red state
|
|
4332
|
+
// for 3s; second press during that window actually kills. ESC while the
|
|
4333
|
+
// card is focused triggers the same flow. Prevents accidental kills.
|
|
4334
|
+
var killConfirmTimer = null;
|
|
4335
|
+
function armKillConfirm() {
|
|
4336
|
+
killBtn.dataset.state = 'confirming';
|
|
4337
|
+
killBtn.textContent = 'Confirm?';
|
|
4338
|
+
if (killConfirmTimer) clearTimeout(killConfirmTimer);
|
|
4339
|
+
killConfirmTimer = setTimeout(function() {
|
|
4340
|
+
killBtn.dataset.state = 'ready';
|
|
4341
|
+
killBtn.textContent = 'Kill';
|
|
4342
|
+
killConfirmTimer = null;
|
|
4343
|
+
}, 3000);
|
|
4344
|
+
}
|
|
4345
|
+
async function performKill() {
|
|
4346
|
+
if (killConfirmTimer) { clearTimeout(killConfirmTimer); killConfirmTimer = null; }
|
|
4347
|
+
killBtn.disabled = true;
|
|
4348
|
+
killBtn.dataset.state = 'killing';
|
|
4349
|
+
killBtn.textContent = '…';
|
|
2225
4350
|
try {
|
|
2226
4351
|
await fetch('/api/task/' + session.sessionId + '/kill', { method: 'POST' });
|
|
2227
4352
|
} catch { /* ignore */ }
|
|
4353
|
+
killBtn.dataset.state = 'ready';
|
|
2228
4354
|
killBtn.textContent = 'Kill';
|
|
4355
|
+
killBtn.disabled = false;
|
|
4356
|
+
}
|
|
4357
|
+
killBtn.dataset.state = 'ready';
|
|
4358
|
+
killBtn.addEventListener('click', function(ev) {
|
|
4359
|
+
ev.stopPropagation();
|
|
4360
|
+
if (killBtn.dataset.state === 'confirming') {
|
|
4361
|
+
performKill();
|
|
4362
|
+
} else {
|
|
4363
|
+
armKillConfirm();
|
|
4364
|
+
}
|
|
4365
|
+
});
|
|
4366
|
+
// ESC keyboard shortcut — fires when the session card has focus within.
|
|
4367
|
+
card.addEventListener('keydown', function(ev) {
|
|
4368
|
+
if (ev.key !== 'Escape') return;
|
|
4369
|
+
if (killBtn.disabled) return;
|
|
4370
|
+
ev.preventDefault();
|
|
4371
|
+
if (killBtn.dataset.state === 'confirming') {
|
|
4372
|
+
performKill();
|
|
4373
|
+
} else {
|
|
4374
|
+
armKillConfirm();
|
|
4375
|
+
}
|
|
2229
4376
|
});
|
|
2230
4377
|
|
|
2231
4378
|
// Iter 12 — expose renderArtifacts so the polling loop can refresh the
|
|
@@ -2239,8 +4386,14 @@ ${SHARED_STYLES}
|
|
|
2239
4386
|
if (!prompt) return;
|
|
2240
4387
|
// Apr 29: empty value = "Auto" option = backend defaults to conductor
|
|
2241
4388
|
// (the supervisor routes). Specialists are explicit overrides.
|
|
4389
|
+
// M5 slice 2: agent === '*' = fan-out (every installed specialist in
|
|
4390
|
+
// parallel via /api/task/batch).
|
|
2242
4391
|
const agent = agentSelect.value;
|
|
2243
4392
|
const cwd = cwdInput.value.trim();
|
|
4393
|
+
if (agent === '*') {
|
|
4394
|
+
await assignParallel(prompt, cwd);
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
2244
4397
|
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
2245
4398
|
try {
|
|
2246
4399
|
const res = await fetch('/api/task', {
|
|
@@ -2249,9 +4402,77 @@ ${SHARED_STYLES}
|
|
|
2249
4402
|
body: JSON.stringify({ prompt, agent: agent || undefined, cwd: cwd || undefined }),
|
|
2250
4403
|
});
|
|
2251
4404
|
const data = await res.json();
|
|
2252
|
-
if (!res.ok) {
|
|
4405
|
+
if (!res.ok) {
|
|
4406
|
+
// v0.6 S3 slice 2 — friendly message on daily-budget refusal so
|
|
4407
|
+
// non-technical users see the WHY + the next move instead of a
|
|
4408
|
+
// raw error code.
|
|
4409
|
+
if (res.status === 429 && data.error === 'daily_budget_exhausted') {
|
|
4410
|
+
alert(data.message || 'Daily budget reached.');
|
|
4411
|
+
} else {
|
|
4412
|
+
alert('Failed: ' + (data.error || res.status));
|
|
4413
|
+
}
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
2253
4416
|
attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
|
|
2254
4417
|
promptEl.value = '';
|
|
4418
|
+
// Spend chip refreshes on its own 60s tick; nudge it now since a
|
|
4419
|
+
// spawn means new spend is imminent.
|
|
4420
|
+
refreshSpendToday();
|
|
4421
|
+
} catch (e) {
|
|
4422
|
+
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
4423
|
+
} finally {
|
|
4424
|
+
spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4428
|
+
// M5 slice 2 — fan-out spawn. Sends the same prompt to every installed
|
|
4429
|
+
// ready specialist in parallel via /api/task/batch. Cap of 5 enforced
|
|
4430
|
+
// by the backend; if the user has more than 5 ready specialists we
|
|
4431
|
+
// take the first 5 in the readiness order (sort already favors ready).
|
|
4432
|
+
async function assignParallel(prompt, cwd) {
|
|
4433
|
+
const readySpecialists = (agentReadiness || [])
|
|
4434
|
+
.filter(a => a.state === 'ready')
|
|
4435
|
+
.map(a => a.id)
|
|
4436
|
+
.slice(0, 5);
|
|
4437
|
+
if (readySpecialists.length < 2) {
|
|
4438
|
+
alert('Fan-out needs at least 2 ready agents. Currently ready: ' + readySpecialists.length);
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4441
|
+
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
4442
|
+
try {
|
|
4443
|
+
const res = await fetch('/api/task/batch', {
|
|
4444
|
+
method: 'POST',
|
|
4445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4446
|
+
body: JSON.stringify({
|
|
4447
|
+
tasks: readySpecialists.map(agentId => ({
|
|
4448
|
+
prompt,
|
|
4449
|
+
agent: agentId,
|
|
4450
|
+
cwd: cwd || undefined,
|
|
4451
|
+
})),
|
|
4452
|
+
}),
|
|
4453
|
+
});
|
|
4454
|
+
const data = await res.json();
|
|
4455
|
+
if (!res.ok) { alert('Batch failed: ' + (data.error || res.status)); return; }
|
|
4456
|
+
// Attach a card per spawned task. Per-task errors get surfaced
|
|
4457
|
+
// via alert listing the failed agents — successful spawns still
|
|
4458
|
+
// attach.
|
|
4459
|
+
const failed = [];
|
|
4460
|
+
for (const t of (data.tasks || [])) {
|
|
4461
|
+
if (t.sessionId) {
|
|
4462
|
+
attachSession({
|
|
4463
|
+
sessionId: t.sessionId,
|
|
4464
|
+
prompt,
|
|
4465
|
+
startedAt: t.startedAt,
|
|
4466
|
+
status: t.status,
|
|
4467
|
+
agent: t.agent,
|
|
4468
|
+
canAppendMessage: false, // batch doesn't surface this; default conservative
|
|
4469
|
+
});
|
|
4470
|
+
} else if (t.error) {
|
|
4471
|
+
failed.push(t.agent + ': ' + (t.reason || t.error));
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
if (failed.length > 0) alert(failed.length + ' of ' + (data.tasks || []).length + ' fan-out spawns failed: ' + failed.join('; '));
|
|
4475
|
+
promptEl.value = '';
|
|
2255
4476
|
} catch (e) {
|
|
2256
4477
|
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
2257
4478
|
} finally {
|
|
@@ -2375,8 +4596,7 @@ ${SHARED_STYLES}
|
|
|
2375
4596
|
'<div class="session-head">' +
|
|
2376
4597
|
'<div class="meta">' +
|
|
2377
4598
|
'<div class="prompt">' +
|
|
2378
|
-
'
|
|
2379
|
-
escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
|
|
4599
|
+
agentBadgeHTML(a.id || a.label) + ' ' + escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
|
|
2380
4600
|
'</div>' +
|
|
2381
4601
|
'<div class="sub">running ' + escape(uptime) + '</div>' +
|
|
2382
4602
|
'</div>' +
|
|
@@ -2406,9 +4626,10 @@ ${SHARED_STYLES}
|
|
|
2406
4626
|
if (!hasAny) { auditRow.classList.add('empty'); return; }
|
|
2407
4627
|
auditRow.classList.remove('empty');
|
|
2408
4628
|
document.getElementById('as-actions').textContent = String(d.actions || 0);
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
4629
|
+
// UX-simplification iter 2 (May 7): dropped writes to hidden as-blocked /
|
|
4630
|
+
// as-trust / as-agents spans — they were updated but never displayed.
|
|
4631
|
+
// The mode-indicator pill in the nav now carries the safety-posture
|
|
4632
|
+
// signal those hidden values were once meant to surface.
|
|
2412
4633
|
}
|
|
2413
4634
|
function refreshAudit() {
|
|
2414
4635
|
fetch('/api/audit/summary').then(r => r.json()).then(updateAudit).catch(() => { /* fine */ });
|
|
@@ -2416,39 +4637,18 @@ ${SHARED_STYLES}
|
|
|
2416
4637
|
refreshAudit();
|
|
2417
4638
|
setInterval(refreshAudit, 30000); // audit log isn't hot; 30s is plenty
|
|
2418
4639
|
|
|
2419
|
-
// Apr 29
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
|
|
2424
|
-
function agoStr(ms) {
|
|
2425
|
-
if (!ms) return '—';
|
|
2426
|
-
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
2427
|
-
if (s < 60) return s + 's ago';
|
|
2428
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
2429
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
2430
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
2431
|
-
}
|
|
4640
|
+
// Apr 29 status-banner is now the single stats-line above ("X actions
|
|
4641
|
+
// today"). UX-simplification iter 2 (May 7) dropped the hidden setText
|
|
4642
|
+
// counters + the per-second timer that updated sb-last for a value
|
|
4643
|
+
// that was never displayed. updateBanner still toggles row visibility
|
|
4644
|
+
// when there's any session activity.
|
|
2432
4645
|
function updateBanner(d) {
|
|
2433
4646
|
const spawned = d.spawned || d.sessions || [];
|
|
2434
|
-
const running = spawned.filter(s => s.status === 'running').length;
|
|
2435
|
-
const blocked = spawned.filter(s => s.status === 'error').length;
|
|
2436
4647
|
const detected = (d.activeAgents || []).length;
|
|
2437
|
-
|
|
2438
|
-
setText('sb-running', running);
|
|
2439
|
-
setText('sb-detected', detected);
|
|
2440
|
-
setText('sb-blocked', blocked);
|
|
2441
|
-
latestLastEditAt = d.lastEditAt || null;
|
|
2442
|
-
setText('sb-last', agoStr(latestLastEditAt));
|
|
2443
|
-
// Reveal the unified stats-line once we have anything to show.
|
|
2444
|
-
if (running + detected + blocked + spawned.length > 0) {
|
|
4648
|
+
if (spawned.length + detected > 0) {
|
|
2445
4649
|
auditRow.classList.remove('empty');
|
|
2446
4650
|
}
|
|
2447
4651
|
}
|
|
2448
|
-
setInterval(() => {
|
|
2449
|
-
const el = document.getElementById('sb-last');
|
|
2450
|
-
if (el && latestLastEditAt) el.textContent = agoStr(latestLastEditAt);
|
|
2451
|
-
}, 1000);
|
|
2452
4652
|
|
|
2453
4653
|
function loadSessions() {
|
|
2454
4654
|
fetch('/api/tasks').then(r => r.json()).then(d => {
|
|
@@ -2462,8 +4662,28 @@ ${SHARED_STYLES}
|
|
|
2462
4662
|
}
|
|
2463
4663
|
pruneStaleAgents(seen);
|
|
2464
4664
|
updateBanner(d);
|
|
4665
|
+
updateShowMoreButton();
|
|
2465
4666
|
}).catch(() => { /* fine */ });
|
|
2466
4667
|
}
|
|
4668
|
+
// claim_user_persona — show "Show all sessions (N more)" button only
|
|
4669
|
+
// when there are more than 5 spawned sessions. Detected-process rows
|
|
4670
|
+
// don't count (they're already hidden by default).
|
|
4671
|
+
function updateShowMoreButton() {
|
|
4672
|
+
const btn = document.getElementById('show-more-sessions');
|
|
4673
|
+
if (!btn) return;
|
|
4674
|
+
const allSessions = sessionsEl.querySelectorAll('.session:not(.observed)');
|
|
4675
|
+
if (allSessions.length > 5 && !document.body.classList.contains('show-all-sessions')) {
|
|
4676
|
+
btn.style.display = 'block';
|
|
4677
|
+
btn.textContent = 'Show all sessions (' + (allSessions.length - 5) + ' more)';
|
|
4678
|
+
} else {
|
|
4679
|
+
btn.style.display = 'none';
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
const showMoreBtn = document.getElementById('show-more-sessions');
|
|
4683
|
+
if (showMoreBtn) showMoreBtn.addEventListener('click', function() {
|
|
4684
|
+
document.body.classList.add('show-all-sessions');
|
|
4685
|
+
updateShowMoreButton();
|
|
4686
|
+
});
|
|
2467
4687
|
loadSessions();
|
|
2468
4688
|
// Refresh detected-agents list + banner every 5s. Iter 12 — also patches
|
|
2469
4689
|
// existing session cards with new finalReport prose when it arrives async.
|
|
@@ -2492,7 +4712,17 @@ ${SHARED_STYLES}
|
|
|
2492
4712
|
const readinessEl = document.getElementById('readiness');
|
|
2493
4713
|
|
|
2494
4714
|
function renderReadiness(report) {
|
|
2495
|
-
|
|
4715
|
+
// Hide the banner unless the state is genuinely actionable. May 5 2026
|
|
4716
|
+
// design pass: the prior api_key_shadow state surfaced a yellow warning
|
|
4717
|
+
// banner with shell-grep jargon for an environment condition that ISNT
|
|
4718
|
+
// a problem -- codehere already uses subscription auth and ignores the
|
|
4719
|
+
// ANTHROPIC_API_KEY env var by design. Per claim_user_persona (HIGH
|
|
4720
|
+
// non-technical-first) + the Open-Agents production-grade design
|
|
4721
|
+
// reference, only show the banner when the user can / must act on it
|
|
4722
|
+
// (needs_install, needs_auth, billed_key_required). Other states stay
|
|
4723
|
+
// observable in /api/readiness for tooling.
|
|
4724
|
+
const ACTIONABLE_STATES = new Set(['needs_install', 'needs_auth', 'billed_key_required']);
|
|
4725
|
+
if (!report || !ACTIONABLE_STATES.has(report.state)) {
|
|
2496
4726
|
readinessEl.style.display = 'none';
|
|
2497
4727
|
return;
|
|
2498
4728
|
}
|
|
@@ -2614,6 +4844,46 @@ ${SHARED_STYLES}
|
|
|
2614
4844
|
margin-top: 4px; padding-left: 22px;
|
|
2615
4845
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
2616
4846
|
}
|
|
4847
|
+
/* Pencil affordance for project rename. Always visible at subtle opacity;
|
|
4848
|
+
brightens only when the user hovers the pencil itself. NO background
|
|
4849
|
+
box at any state. Inline SVG so it renders crisp at any size and
|
|
4850
|
+
inherits color from CSS. */
|
|
4851
|
+
.channel-rename {
|
|
4852
|
+
background: transparent; border: none; cursor: pointer;
|
|
4853
|
+
color: var(--fg-30); padding: 4px;
|
|
4854
|
+
transition: color 0.12s ease;
|
|
4855
|
+
flex: 0 0 auto;
|
|
4856
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
4857
|
+
}
|
|
4858
|
+
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
4859
|
+
.channel-rename:hover { color: var(--fg); background: transparent; }
|
|
4860
|
+
|
|
4861
|
+
/* Inline edit-in-place for project rename. May 6 2026: replaces the
|
|
4862
|
+
prior window.prompt() browser dialog (cheap UX; not enterprise-grade
|
|
4863
|
+
per Open-Agents reference). Input matches channel-name typography so
|
|
4864
|
+
the swap feels seamless. */
|
|
4865
|
+
.channel-rename-input {
|
|
4866
|
+
background: transparent;
|
|
4867
|
+
border: 1px solid var(--accent);
|
|
4868
|
+
border-radius: 3px;
|
|
4869
|
+
color: var(--fg);
|
|
4870
|
+
font: inherit;
|
|
4871
|
+
font-weight: 500;
|
|
4872
|
+
padding: 1px 4px;
|
|
4873
|
+
margin: -2px -5px;
|
|
4874
|
+
width: calc(100% + 10px);
|
|
4875
|
+
outline: none;
|
|
4876
|
+
box-sizing: border-box;
|
|
4877
|
+
}
|
|
4878
|
+
.channel-rename-input.error {
|
|
4879
|
+
border-color: var(--block);
|
|
4880
|
+
animation: rename-shake 0.25s ease;
|
|
4881
|
+
}
|
|
4882
|
+
@keyframes rename-shake {
|
|
4883
|
+
0%, 100% { transform: translateX(0); }
|
|
4884
|
+
25% { transform: translateX(-2px); }
|
|
4885
|
+
75% { transform: translateX(2px); }
|
|
4886
|
+
}
|
|
2617
4887
|
.pane-empty {
|
|
2618
4888
|
color: var(--fg-30); font-size: 10.5px;
|
|
2619
4889
|
padding: 8px; text-align: center;
|
|
@@ -2663,8 +4933,11 @@ ${SHARED_STYLES}
|
|
|
2663
4933
|
font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
|
2664
4934
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
2665
4935
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
4936
|
+
/* Pattern 8 ride-along revert: f830183 demoted .completed to muted neutral.
|
|
4937
|
+
Restoring green — actionable success is a load-bearing semantic signal
|
|
4938
|
+
in agentic UX. */
|
|
4939
|
+
.task .status.completed { background: rgba(40,200,64,0.10); color: var(--allow); border: 1px solid rgba(40,200,64,0.20); }
|
|
4940
|
+
.task .status.running { background: var(--accent-wash); color: var(--accent); border: 1px solid rgba(255,154,118,0.25); }
|
|
2668
4941
|
.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); }
|
|
2669
4942
|
.task .status.abandoned { background: rgba(255,255,255,0.04); color: var(--fg-40); border: 1px solid var(--border); }
|
|
2670
4943
|
.task .ts { font-family: var(--mono); font-size: 11px; color: var(--fg-40); margin-left: auto; }
|
|
@@ -2727,6 +5000,8 @@ ${SHARED_STYLES}
|
|
|
2727
5000
|
<a href="/" class="nav-link">Tasks</a>
|
|
2728
5001
|
<a href="/threads" class="nav-link active">Threads</a>
|
|
2729
5002
|
<a href="/history" class="nav-link">History</a>
|
|
5003
|
+
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
5004
|
+
<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>
|
|
2730
5005
|
</nav>
|
|
2731
5006
|
|
|
2732
5007
|
<div class="shell slack-shape">
|
|
@@ -2780,6 +5055,9 @@ ${SHARED_STYLES}
|
|
|
2780
5055
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2781
5056
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
2782
5057
|
}
|
|
5058
|
+
// Per-agent badge helpers shared with TASK_HTML / HISTORY_HTML — single
|
|
5059
|
+
// source of truth in AGENT_BADGE_JS at the top of this module.
|
|
5060
|
+
${AGENT_BADGE_JS}
|
|
2783
5061
|
function timeAgo(ts) {
|
|
2784
5062
|
if (!ts) return '';
|
|
2785
5063
|
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
@@ -2797,7 +5075,12 @@ ${SHARED_STYLES}
|
|
|
2797
5075
|
if (ms == null) return '';
|
|
2798
5076
|
if (ms < 1000) return ms + 'ms';
|
|
2799
5077
|
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
2800
|
-
return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
5078
|
+
if (ms < 3600000) return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
5079
|
+
if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
|
|
5080
|
+
// Days-scale: this is almost always a stale-running session that
|
|
5081
|
+
// got reconciled days later. Show "Nd" — actual elapsed runtime is
|
|
5082
|
+
// unknowable so don't pretend "108h 6m" carries meaning.
|
|
5083
|
+
return Math.floor(ms / 86400000) + 'd';
|
|
2801
5084
|
}
|
|
2802
5085
|
function shortPath(p) {
|
|
2803
5086
|
if (!p) return '~';
|
|
@@ -2888,9 +5171,26 @@ ${SHARED_STYLES}
|
|
|
2888
5171
|
return;
|
|
2889
5172
|
}
|
|
2890
5173
|
|
|
2891
|
-
|
|
5174
|
+
// v0.6 — render-cap to keep huge projects (685+ turns on the founder's
|
|
5175
|
+
// dev repo) from blowing the page to ~80,000px tall. "Show all" toggle
|
|
5176
|
+
// mirrors the dashboard 5-card cap pattern. Chronological order
|
|
5177
|
+
// preserved (Slack-shape: oldest first, recent at bottom).
|
|
5178
|
+
const RENDER_CAP = 25;
|
|
5179
|
+
const totalTurns = thread.tasks.length;
|
|
5180
|
+
const showAll = container.dataset.showAllTurns === 'true';
|
|
5181
|
+
const renderedTurns = showAll ? thread.tasks : thread.tasks.slice(-RENDER_CAP);
|
|
5182
|
+
const isCapped = !showAll && totalTurns > RENDER_CAP;
|
|
5183
|
+
|
|
5184
|
+
if (isCapped) {
|
|
5185
|
+
html += '<div class="section-label" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
|
|
5186
|
+
'<span>Thread · showing <b>' + renderedTurns.length + '</b> most recent of ' + totalTurns + ' turns</span>' +
|
|
5187
|
+
'<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>' +
|
|
5188
|
+
'</div>';
|
|
5189
|
+
} else {
|
|
5190
|
+
html += '<div class="section-label">Thread · ' + totalTurns + ' turn' + (totalTurns === 1 ? '' : 's') + '</div>';
|
|
5191
|
+
}
|
|
2892
5192
|
|
|
2893
|
-
for (const t of
|
|
5193
|
+
for (const t of renderedTurns) {
|
|
2894
5194
|
const status = (t.status || '').toLowerCase();
|
|
2895
5195
|
const stepsHtml = (t.steps && t.steps.length)
|
|
2896
5196
|
? '<div class="steps">' + t.steps.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') + '</div>'
|
|
@@ -2901,7 +5201,8 @@ ${SHARED_STYLES}
|
|
|
2901
5201
|
|
|
2902
5202
|
html += '<div class="task">' +
|
|
2903
5203
|
'<div class="row1">' +
|
|
2904
|
-
|
|
5204
|
+
agentBadgeHTML(t.agent || 'unknown') +
|
|
5205
|
+
' <span class="agent-name">' + escapeHtml(agentLabel(t.agent || 'unknown')) + '</span>' +
|
|
2905
5206
|
'<span class="status ' + escapeHtml(status) + '">' + escapeHtml(status || 'unknown') + '</span>' +
|
|
2906
5207
|
'<span class="ts">' + escapeHtml(fmtTime(t.startedAt)) + ' · ' + escapeHtml(timeAgo(t.startedAt)) + '</span>' +
|
|
2907
5208
|
'</div>' +
|
|
@@ -2911,6 +5212,16 @@ ${SHARED_STYLES}
|
|
|
2911
5212
|
'</div>';
|
|
2912
5213
|
}
|
|
2913
5214
|
container.innerHTML = html;
|
|
5215
|
+
// Wire the "Show all N turns" toggle (rendered only when capped). Sets
|
|
5216
|
+
// the dataset flag and re-runs renderThread with the same data so the
|
|
5217
|
+
// cap branch falls through.
|
|
5218
|
+
const showAllBtn = container.querySelector('.show-all-turns');
|
|
5219
|
+
if (showAllBtn) {
|
|
5220
|
+
showAllBtn.addEventListener('click', () => {
|
|
5221
|
+
container.dataset.showAllTurns = 'true';
|
|
5222
|
+
renderThread(thread);
|
|
5223
|
+
});
|
|
5224
|
+
}
|
|
2914
5225
|
const lastTask = thread.tasks[thread.tasks.length - 1];
|
|
2915
5226
|
setReplyState(lastTask.sessionId, lastTask.agent);
|
|
2916
5227
|
}
|
|
@@ -2925,16 +5236,21 @@ ${SHARED_STYLES}
|
|
|
2925
5236
|
const active = p.slug === currentSlug ? ' active' : '';
|
|
2926
5237
|
const name = escapeHtml(p.title || '(untitled)');
|
|
2927
5238
|
const cwdHint = escapeHtml(shortPath(p.cwd));
|
|
5239
|
+
// Pencil affordance — visible on hover via CSS .channel:hover .channel-rename.
|
|
5240
|
+
// Triggers prompt() for v1 simplicity; inline input UX is a future iteration.
|
|
2928
5241
|
return '<div class="channel' + active + '" data-slug="' + escapeHtml(p.slug) + '">' +
|
|
2929
5242
|
'<span class="channel-glyph">#</span>' +
|
|
2930
5243
|
'<div style="flex:1;min-width:0">' +
|
|
2931
5244
|
'<div class="channel-name">' + name + '</div>' +
|
|
2932
5245
|
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
2933
5246
|
'</div>' +
|
|
5247
|
+
'<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>' +
|
|
2934
5248
|
'</div>';
|
|
2935
5249
|
}).join('');
|
|
2936
5250
|
projectsList.querySelectorAll('.channel').forEach(el => {
|
|
2937
|
-
el.addEventListener('click', () => {
|
|
5251
|
+
el.addEventListener('click', (ev) => {
|
|
5252
|
+
// Skip channel-switch when the click was the rename button — that has its own handler.
|
|
5253
|
+
if (ev.target && (ev.target).classList && (ev.target).classList.contains('channel-rename')) return;
|
|
2938
5254
|
const slug = el.getAttribute('data-slug');
|
|
2939
5255
|
if (!slug || slug === currentSlug) return;
|
|
2940
5256
|
currentSlug = slug;
|
|
@@ -2945,6 +5261,72 @@ ${SHARED_STYLES}
|
|
|
2945
5261
|
history.replaceState(null, '', url.toString());
|
|
2946
5262
|
});
|
|
2947
5263
|
});
|
|
5264
|
+
projectsList.querySelectorAll('.channel-rename').forEach(btn => {
|
|
5265
|
+
btn.addEventListener('click', (ev) => {
|
|
5266
|
+
ev.stopPropagation(); // don't trigger channel-switch
|
|
5267
|
+
const slug = btn.getAttribute('data-slug');
|
|
5268
|
+
if (!slug) return;
|
|
5269
|
+
const channel = btn.closest('.channel');
|
|
5270
|
+
const project = list.find(p => p.slug === slug);
|
|
5271
|
+
const current = (project && project.title) || '';
|
|
5272
|
+
startInlineRename(channel, slug, current, () => {
|
|
5273
|
+
loadProjects();
|
|
5274
|
+
// Renamed project may be the visible one — refresh the main pane
|
|
5275
|
+
// header immediately instead of waiting for the 5s poll. Without
|
|
5276
|
+
// this, the sidebar updates but the open thread keeps the old title.
|
|
5277
|
+
if (slug === currentSlug) loadThread(slug);
|
|
5278
|
+
});
|
|
5279
|
+
});
|
|
5280
|
+
});
|
|
5281
|
+
}
|
|
5282
|
+
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
5283
|
+
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
5284
|
+
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
5285
|
+
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
5286
|
+
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
5287
|
+
if (!channelEl) return;
|
|
5288
|
+
const nameEl = channelEl.querySelector('.channel-name');
|
|
5289
|
+
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
5290
|
+
nameEl.dataset.editing = '1';
|
|
5291
|
+
const originalText = nameEl.textContent;
|
|
5292
|
+
const input = document.createElement('input');
|
|
5293
|
+
input.type = 'text';
|
|
5294
|
+
input.value = currentTitle;
|
|
5295
|
+
input.maxLength = 100;
|
|
5296
|
+
input.className = 'channel-rename-input';
|
|
5297
|
+
nameEl.textContent = '';
|
|
5298
|
+
nameEl.appendChild(input);
|
|
5299
|
+
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
5300
|
+
let cleanedUp = false;
|
|
5301
|
+
function cleanup(restoreText) {
|
|
5302
|
+
if (cleanedUp) return;
|
|
5303
|
+
cleanedUp = true;
|
|
5304
|
+
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
5305
|
+
nameEl.textContent = restoreText;
|
|
5306
|
+
delete nameEl.dataset.editing;
|
|
5307
|
+
}
|
|
5308
|
+
function commit() {
|
|
5309
|
+
if (cleanedUp) return;
|
|
5310
|
+
const trimmed = input.value.trim();
|
|
5311
|
+
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
5312
|
+
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
5313
|
+
method: 'POST',
|
|
5314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5315
|
+
body: JSON.stringify({ title: trimmed }),
|
|
5316
|
+
})
|
|
5317
|
+
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
5318
|
+
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
5319
|
+
.catch(() => {
|
|
5320
|
+
input.classList.remove('error');
|
|
5321
|
+
void input.offsetWidth;
|
|
5322
|
+
input.classList.add('error');
|
|
5323
|
+
});
|
|
5324
|
+
}
|
|
5325
|
+
input.addEventListener('keydown', (ev) => {
|
|
5326
|
+
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
5327
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
5328
|
+
});
|
|
5329
|
+
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
2948
5330
|
}
|
|
2949
5331
|
|
|
2950
5332
|
function presenceClass(state) {
|
|
@@ -2966,6 +5348,10 @@ ${SHARED_STYLES}
|
|
|
2966
5348
|
|
|
2967
5349
|
function loadThread(slug) {
|
|
2968
5350
|
if (!slug) { container.innerHTML = '<div class="empty"><div class="pill">No project selected</div></div>'; setReplyState(null, null); return; }
|
|
5351
|
+
// Reset the show-all-turns flag on every project switch so a user
|
|
5352
|
+
// moving from a 686-turn project to a 5-turn one doesn't carry the
|
|
5353
|
+
// toggled state across.
|
|
5354
|
+
delete container.dataset.showAllTurns;
|
|
2969
5355
|
fetch('/api/projects/' + encodeURIComponent(slug))
|
|
2970
5356
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
2971
5357
|
.then(data => renderThread(data.thread))
|