codehere 0.5.0-alpha.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +89 -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/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- 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 +515 -15
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +2872 -220
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
- 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/presentation/cli/repl.js +29 -0
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +2 -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,23 +1411,137 @@ ${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';
|
|
1463
|
+
// v0.6 — cap DOM rows so a long-open /history tab doesn't accumulate
|
|
1464
|
+
// thousands of <li>s (~50 events/min × hours = browser slowdown).
|
|
1465
|
+
// Same cap-pattern as /threads (turns) and dashboard (sessions).
|
|
1466
|
+
// Newest-first prepend means the OLDEST is the LAST child; trim that.
|
|
1467
|
+
const MAX_FEED_ROWS = 500;
|
|
1468
|
+
while (feed.children.length > MAX_FEED_ROWS) {
|
|
1469
|
+
feed.removeChild(feed.lastChild);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Bug A5 — filter state. Active outcome filter and active tool filter
|
|
1474
|
+
// (each null means "no filter"). Persisted in the URL hash so a
|
|
1475
|
+
// bookmarkable view is achievable + reload preserves the filter.
|
|
1476
|
+
const filterState = { outcome: null, tool: null };
|
|
1477
|
+
function readFilterFromHash() {
|
|
1478
|
+
const hash = (location.hash || '').replace(/^#/, '');
|
|
1479
|
+
if (!hash) return;
|
|
1480
|
+
const params = new URLSearchParams(hash);
|
|
1481
|
+
const o = params.get('outcome');
|
|
1482
|
+
const t = params.get('tool');
|
|
1483
|
+
if (o && o !== 'all') filterState.outcome = o;
|
|
1484
|
+
if (t) filterState.tool = t;
|
|
1485
|
+
}
|
|
1486
|
+
function writeFilterToHash() {
|
|
1487
|
+
const params = new URLSearchParams();
|
|
1488
|
+
if (filterState.outcome) params.set('outcome', filterState.outcome);
|
|
1489
|
+
if (filterState.tool) params.set('tool', filterState.tool);
|
|
1490
|
+
const next = params.toString();
|
|
1491
|
+
location.hash = next ? '#' + next : '';
|
|
1492
|
+
}
|
|
1493
|
+
function rowMatches(li) {
|
|
1494
|
+
const o = li.dataset.outcome || '';
|
|
1495
|
+
const t = li.dataset.tool || '';
|
|
1496
|
+
if (filterState.outcome && o !== filterState.outcome) return false;
|
|
1497
|
+
if (filterState.tool && t !== filterState.tool) return false;
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
function applyFilterToRow(li) {
|
|
1501
|
+
if (rowMatches(li)) li.classList.add('feed-match');
|
|
1502
|
+
else li.classList.remove('feed-match');
|
|
1503
|
+
}
|
|
1504
|
+
function applyFilterToAllRows() {
|
|
1505
|
+
const active = !!(filterState.outcome || filterState.tool);
|
|
1506
|
+
feed.classList.toggle('filtered', active);
|
|
1507
|
+
Array.prototype.forEach.call(feed.querySelectorAll('li'), applyFilterToRow);
|
|
1508
|
+
}
|
|
1509
|
+
function syncChipUI() {
|
|
1510
|
+
const chips = document.querySelectorAll('#feed-filters .filter-chip');
|
|
1511
|
+
Array.prototype.forEach.call(chips, function(chip) {
|
|
1512
|
+
const fk = chip.dataset.filter;
|
|
1513
|
+
const fv = chip.dataset.value;
|
|
1514
|
+
if (fk === 'clear') return;
|
|
1515
|
+
let isActive = false;
|
|
1516
|
+
if (fk === 'outcome') {
|
|
1517
|
+
isActive = (fv === 'all' && !filterState.outcome) || (fv !== 'all' && filterState.outcome === fv);
|
|
1518
|
+
} else if (fk === 'tool') {
|
|
1519
|
+
isActive = filterState.tool === fv;
|
|
1520
|
+
}
|
|
1521
|
+
chip.classList.toggle('active', isActive);
|
|
1522
|
+
});
|
|
628
1523
|
}
|
|
1524
|
+
document.addEventListener('click', function(ev) {
|
|
1525
|
+
const chip = ev.target && ev.target.closest && ev.target.closest('.filter-chip');
|
|
1526
|
+
if (!chip) return;
|
|
1527
|
+
const fk = chip.dataset.filter;
|
|
1528
|
+
const fv = chip.dataset.value;
|
|
1529
|
+
if (fk === 'clear') {
|
|
1530
|
+
filterState.outcome = null;
|
|
1531
|
+
filterState.tool = null;
|
|
1532
|
+
} else if (fk === 'outcome') {
|
|
1533
|
+
filterState.outcome = fv === 'all' ? null : fv;
|
|
1534
|
+
} else if (fk === 'tool') {
|
|
1535
|
+
// Toggle: clicking an already-active tool chip clears the tool filter.
|
|
1536
|
+
filterState.tool = filterState.tool === fv ? null : fv;
|
|
1537
|
+
}
|
|
1538
|
+
writeFilterToHash();
|
|
1539
|
+
syncChipUI();
|
|
1540
|
+
applyFilterToAllRows();
|
|
1541
|
+
});
|
|
1542
|
+
// Init from URL hash
|
|
1543
|
+
readFilterFromHash();
|
|
1544
|
+
syncChipUI();
|
|
629
1545
|
|
|
630
1546
|
// ── Status strip (answers C/A/B/D) ───────────────────────────────────
|
|
631
1547
|
const events = []; // accumulate for rolling-window stats
|
|
@@ -679,6 +1595,12 @@ ${SHARED_STYLES}
|
|
|
679
1595
|
const entry = JSON.parse(ev.data);
|
|
680
1596
|
entry._ts = parseTs(entry);
|
|
681
1597
|
events.push(entry);
|
|
1598
|
+
// v0.6 — paired with the DOM cap above. Stats only need recent
|
|
1599
|
+
// events; trimming the head keeps recomputeStats O(MAX) not O(forever).
|
|
1600
|
+
const MAX_EVENTS_IN_MEMORY = 500;
|
|
1601
|
+
if (events.length > MAX_EVENTS_IN_MEMORY) {
|
|
1602
|
+
events.splice(0, events.length - MAX_EVENTS_IN_MEMORY);
|
|
1603
|
+
}
|
|
682
1604
|
render(entry);
|
|
683
1605
|
recomputeStats();
|
|
684
1606
|
} catch { /* skip malformed */ }
|
|
@@ -686,6 +1608,91 @@ ${SHARED_STYLES}
|
|
|
686
1608
|
|
|
687
1609
|
// Keep "Xs/Xm ago" fresh even when no new events arrive
|
|
688
1610
|
setInterval(() => { if (events.length) recomputeStats(); }, 1000);
|
|
1611
|
+
|
|
1612
|
+
// P12 slice — poll /api/audit/verify every 60s; show the tamper
|
|
1613
|
+
// banner if the hash chain breaks. First poll runs at load.
|
|
1614
|
+
function pollAuditChain() {
|
|
1615
|
+
fetch('/api/audit/verify')
|
|
1616
|
+
.then(r => r.ok ? r.json() : null)
|
|
1617
|
+
.then(d => {
|
|
1618
|
+
if (!d) return;
|
|
1619
|
+
const banner = document.getElementById('tamper-banner');
|
|
1620
|
+
const detail = document.getElementById('tb-detail');
|
|
1621
|
+
if (!banner) return;
|
|
1622
|
+
if (d.valid) {
|
|
1623
|
+
banner.dataset.visible = 'false';
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
// claim_user_persona HIGH — banner hidden from non-technical
|
|
1627
|
+
// users (CSS gates on body.advanced-on). data-visible flag
|
|
1628
|
+
// tells CSS whether to show when advanced toggle flips on.
|
|
1629
|
+
// Severity-driven copy below stays for technical readers.
|
|
1630
|
+
const errs = Array.isArray(d.errors) ? d.errors : [];
|
|
1631
|
+
const hasHashMismatch = errs.some(function(e) { return e.reason === 'hash_mismatch' || e.reason === 'parse_failure'; });
|
|
1632
|
+
const titleEl = banner.querySelector('.tb-text');
|
|
1633
|
+
const iconEl = banner.querySelector('.tb-icon');
|
|
1634
|
+
if (hasHashMismatch) {
|
|
1635
|
+
banner.dataset.severity = 'tamper';
|
|
1636
|
+
if (titleEl) titleEl.textContent = 'Audit log tampering detected.';
|
|
1637
|
+
if (iconEl) iconEl.textContent = '⚠';
|
|
1638
|
+
} else {
|
|
1639
|
+
banner.dataset.severity = 'anomaly';
|
|
1640
|
+
if (titleEl) titleEl.textContent = 'Audit chain anomaly (likely a daemon restart).';
|
|
1641
|
+
if (iconEl) iconEl.textContent = 'ℹ';
|
|
1642
|
+
}
|
|
1643
|
+
banner.dataset.visible = 'true';
|
|
1644
|
+
if (detail && errs.length > 0) {
|
|
1645
|
+
const first = errs[0];
|
|
1646
|
+
detail.textContent = '· ' + (errs.length === 1
|
|
1647
|
+
? 'line ' + first.lineNumber + ': ' + first.reason
|
|
1648
|
+
: errs.length + ' issues (first at line ' + first.lineNumber + ')');
|
|
1649
|
+
}
|
|
1650
|
+
})
|
|
1651
|
+
.catch(() => { /* leave banner state */ });
|
|
1652
|
+
}
|
|
1653
|
+
pollAuditChain();
|
|
1654
|
+
setInterval(pollAuditChain, 60000);
|
|
1655
|
+
|
|
1656
|
+
// P12 advanced toggle — flip body.advanced-on. Persisted via the URL
|
|
1657
|
+
// hash (?advanced=1) so reload preserves. Click toggles back off.
|
|
1658
|
+
const advancedToggle = document.getElementById('advanced-toggle');
|
|
1659
|
+
function setAdvanced(on) {
|
|
1660
|
+
document.body.classList.toggle('advanced-on', !!on);
|
|
1661
|
+
if (advancedToggle) advancedToggle.classList.toggle('active', !!on);
|
|
1662
|
+
try {
|
|
1663
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1664
|
+
if (on) params.set('advanced', '1'); else params.delete('advanced');
|
|
1665
|
+
const next = params.toString();
|
|
1666
|
+
location.hash = next ? '#' + next : '';
|
|
1667
|
+
} catch { /* hash write best-effort */ }
|
|
1668
|
+
}
|
|
1669
|
+
// Hash-on-load read
|
|
1670
|
+
try {
|
|
1671
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1672
|
+
if (params.get('advanced') === '1') setAdvanced(true);
|
|
1673
|
+
if (params.get('more') === '1') setMoreFilters(true);
|
|
1674
|
+
} catch { /* ignore */ }
|
|
1675
|
+
if (advancedToggle) advancedToggle.addEventListener('click', function() {
|
|
1676
|
+
setAdvanced(!document.body.classList.contains('advanced-on'));
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
// claim_user_persona HIGH — More-filters disclosure for the per-tool
|
|
1680
|
+
// + per-agent chips. Default closed so non-technical users see 4
|
|
1681
|
+
// chips instead of 14.
|
|
1682
|
+
const moreToggle = document.getElementById('more-filters-toggle');
|
|
1683
|
+
function setMoreFilters(on) {
|
|
1684
|
+
document.body.classList.toggle('more-filters-on', !!on);
|
|
1685
|
+
if (moreToggle) moreToggle.classList.toggle('active', !!on);
|
|
1686
|
+
try {
|
|
1687
|
+
const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
1688
|
+
if (on) params.set('more', '1'); else params.delete('more');
|
|
1689
|
+
const next = params.toString();
|
|
1690
|
+
location.hash = next ? '#' + next : '';
|
|
1691
|
+
} catch { /* hash write best-effort */ }
|
|
1692
|
+
}
|
|
1693
|
+
if (moreToggle) moreToggle.addEventListener('click', function() {
|
|
1694
|
+
setMoreFilters(!document.body.classList.contains('more-filters-on'));
|
|
1695
|
+
});
|
|
689
1696
|
})();
|
|
690
1697
|
</script>
|
|
691
1698
|
</body>
|
|
@@ -710,11 +1717,6 @@ ${SHARED_STYLES}
|
|
|
710
1717
|
display: flex; align-items: center; justify-content: space-between;
|
|
711
1718
|
}
|
|
712
1719
|
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
1720
|
header .title { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
719
1721
|
header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
|
|
720
1722
|
header nav { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; }
|
|
@@ -794,6 +1796,20 @@ ${SHARED_STYLES}
|
|
|
794
1796
|
color: var(--fg-30); font-size: 10.5px;
|
|
795
1797
|
padding: 8px; text-align: center;
|
|
796
1798
|
}
|
|
1799
|
+
/* Pencil rename button — was missing from TASK_HTML's style block, so the
|
|
1800
|
+
SVG rendered without sizing constraints and showed as a white square on
|
|
1801
|
+
the active project row. Same rules as THREAD_HTML's copy. */
|
|
1802
|
+
.channel-rename {
|
|
1803
|
+
background: transparent; border: none; cursor: pointer;
|
|
1804
|
+
color: var(--fg-30); padding: 4px;
|
|
1805
|
+
transition: color 0.12s ease;
|
|
1806
|
+
flex: 0 0 auto;
|
|
1807
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1808
|
+
outline: none;
|
|
1809
|
+
}
|
|
1810
|
+
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
1811
|
+
.channel-rename:hover, .channel-rename:focus { color: var(--fg); background: transparent; }
|
|
1812
|
+
.channel-rename:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
|
797
1813
|
|
|
798
1814
|
/* ── Readiness banner (F1a) ─────────────────────────────────────────── */
|
|
799
1815
|
.readiness {
|
|
@@ -829,6 +1845,88 @@ ${SHARED_STYLES}
|
|
|
829
1845
|
}
|
|
830
1846
|
.readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
831
1847
|
|
|
1848
|
+
/* v0.6.x P0 — failover offer banner. Amber stripe: "Agent A rate-limited,
|
|
1849
|
+
switch to Agent B?" with one-click dismiss + accept buttons. */
|
|
1850
|
+
.failover-banner {
|
|
1851
|
+
margin: 14px 0 0; padding: 14px 20px;
|
|
1852
|
+
border: 1px solid rgba(254,188,46,0.3);
|
|
1853
|
+
background: rgba(254,188,46,0.06);
|
|
1854
|
+
border-radius: 10px;
|
|
1855
|
+
font-size: 13px; line-height: 1.5;
|
|
1856
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1857
|
+
gap: 12px; flex-wrap: wrap;
|
|
1858
|
+
}
|
|
1859
|
+
.failover-banner .fb-msg { flex: 1; color: var(--fg-70); min-width: 200px; }
|
|
1860
|
+
.failover-banner .fb-msg b { color: var(--fg); font-weight: 600; }
|
|
1861
|
+
.failover-banner .fb-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
|
1862
|
+
.failover-banner .fb-actions button {
|
|
1863
|
+
font-family: var(--mono); font-size: 11px;
|
|
1864
|
+
padding: 5px 14px; border-radius: 6px; cursor: pointer;
|
|
1865
|
+
border: 1px solid var(--border); background: transparent;
|
|
1866
|
+
color: var(--fg-70); transition: color 0.15s, border-color 0.15s;
|
|
1867
|
+
}
|
|
1868
|
+
.failover-banner .fb-actions button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1869
|
+
.failover-banner .fb-actions button.fb-accept {
|
|
1870
|
+
background: rgba(254,188,46,0.12); border-color: rgba(254,188,46,0.4);
|
|
1871
|
+
color: var(--warn); font-weight: 600;
|
|
1872
|
+
}
|
|
1873
|
+
.failover-banner .fb-actions button.fb-accept:hover { background: rgba(254,188,46,0.2); }
|
|
1874
|
+
|
|
1875
|
+
/* v0.6 S2 — first-time onboarding card. Quiet authority: peach
|
|
1876
|
+
accent border + subtle glass, doesn't compete with the textarea
|
|
1877
|
+
below. Removed once dismissed OR once any session exists. */
|
|
1878
|
+
.onboarding-card {
|
|
1879
|
+
margin: 30px 0 18px; padding: 24px 28px;
|
|
1880
|
+
border: 1px solid rgba(255,154,118,0.25);
|
|
1881
|
+
background: linear-gradient(135deg, rgba(255,154,118,0.04) 0%, rgba(74,144,226,0.03) 100%);
|
|
1882
|
+
border-radius: 12px;
|
|
1883
|
+
position: relative; z-index: 1;
|
|
1884
|
+
}
|
|
1885
|
+
.onboarding-card .ob-head {
|
|
1886
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
1887
|
+
margin-bottom: 6px;
|
|
1888
|
+
}
|
|
1889
|
+
.onboarding-card .ob-title {
|
|
1890
|
+
font-family: var(--sans); font-size: 18px; font-weight: 700;
|
|
1891
|
+
color: var(--fg);
|
|
1892
|
+
background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
|
|
1893
|
+
-webkit-background-clip: text; background-clip: text;
|
|
1894
|
+
-webkit-text-fill-color: transparent;
|
|
1895
|
+
}
|
|
1896
|
+
.onboarding-card .ob-close {
|
|
1897
|
+
background: transparent; border: none; cursor: pointer;
|
|
1898
|
+
color: var(--fg-40); font-size: 22px; line-height: 1;
|
|
1899
|
+
padding: 0 4px;
|
|
1900
|
+
transition: color 0.15s ease;
|
|
1901
|
+
}
|
|
1902
|
+
.onboarding-card .ob-close:hover { color: var(--fg); }
|
|
1903
|
+
.onboarding-card .ob-blurb {
|
|
1904
|
+
font-family: var(--sans); font-size: 13px; line-height: 1.55;
|
|
1905
|
+
color: var(--fg-70); margin-bottom: 16px; max-width: 640px;
|
|
1906
|
+
}
|
|
1907
|
+
.onboarding-card .ob-steps {
|
|
1908
|
+
list-style: none; padding: 0; margin: 0;
|
|
1909
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
1910
|
+
}
|
|
1911
|
+
.onboarding-card .ob-steps li {
|
|
1912
|
+
display: flex; gap: 12px; align-items: flex-start;
|
|
1913
|
+
}
|
|
1914
|
+
.onboarding-card .ob-num {
|
|
1915
|
+
flex: 0 0 22px; height: 22px; line-height: 22px;
|
|
1916
|
+
border-radius: 50%; text-align: center;
|
|
1917
|
+
background: rgba(255,154,118,0.15);
|
|
1918
|
+
color: var(--accent);
|
|
1919
|
+
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
|
1920
|
+
}
|
|
1921
|
+
.onboarding-card .ob-step-title {
|
|
1922
|
+
font-family: var(--sans); font-size: 13px; font-weight: 600;
|
|
1923
|
+
color: var(--fg); margin-bottom: 2px;
|
|
1924
|
+
}
|
|
1925
|
+
.onboarding-card .ob-step-body {
|
|
1926
|
+
font-family: var(--sans); font-size: 12px; line-height: 1.5;
|
|
1927
|
+
color: var(--fg-50);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
832
1930
|
.intake { padding: 36px 0 24px; position: relative; z-index: 1; }
|
|
833
1931
|
.intake h1 {
|
|
834
1932
|
margin: 0 0 6px;
|
|
@@ -847,6 +1945,47 @@ ${SHARED_STYLES}
|
|
|
847
1945
|
resize: vertical; outline: none;
|
|
848
1946
|
}
|
|
849
1947
|
.intake textarea:focus { border-color: rgba(255,154,118,0.4); }
|
|
1948
|
+
|
|
1949
|
+
/* Pattern 5 · Composer + cost row. Sits between the textarea and the
|
|
1950
|
+
intake-row so the user sees: who's going to run this · where ·
|
|
1951
|
+
how much of the context window the prompt consumes · est. cost.
|
|
1952
|
+
Live-updates on every keystroke + agent/cwd change.
|
|
1953
|
+
Per docs/design/AGENTIC_UX.md Pattern 5. */
|
|
1954
|
+
.composer-meta {
|
|
1955
|
+
display: flex; gap: 10px; align-items: center;
|
|
1956
|
+
margin-top: 6px; padding: 0 4px;
|
|
1957
|
+
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
1958
|
+
min-height: 18px;
|
|
1959
|
+
}
|
|
1960
|
+
.composer-meta .cm-pill {
|
|
1961
|
+
display: inline-flex; gap: 4px; align-items: center;
|
|
1962
|
+
padding: 1px 8px; border-radius: 999px;
|
|
1963
|
+
border: 1px solid var(--border-soft);
|
|
1964
|
+
background: rgba(255,255,255,0.02);
|
|
1965
|
+
}
|
|
1966
|
+
.composer-meta .cm-pill b { color: var(--fg-70); font-weight: 500; }
|
|
1967
|
+
.composer-meta .cm-sep { color: var(--fg-20); user-select: none; }
|
|
1968
|
+
.composer-meta .cm-budget[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1969
|
+
.composer-meta .cm-budget[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1970
|
+
.composer-meta .cm-cost.free { color: var(--allow); border-color: rgba(40,200,64,0.22); background: rgba(40,200,64,0.06); }
|
|
1971
|
+
.composer-meta .cm-cost.paid { color: var(--fg-70); }
|
|
1972
|
+
.composer-meta .cm-spend { color: var(--fg-70); cursor: help; }
|
|
1973
|
+
.composer-meta .cm-spend b { color: var(--fg-90); }
|
|
1974
|
+
.composer-meta .cm-spend[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
|
|
1975
|
+
.composer-meta .cm-spend[data-state="warn"] b{ color: var(--warn); }
|
|
1976
|
+
.composer-meta .cm-spend[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
|
|
1977
|
+
.composer-meta .cm-spend[data-state="block"] b{ color: var(--block); }
|
|
1978
|
+
.composer-meta .cm-spacer { flex: 1; }
|
|
1979
|
+
.composer-meta .cm-hint {
|
|
1980
|
+
color: var(--fg-30); font-size: 10.5px;
|
|
1981
|
+
margin-left: 4px;
|
|
1982
|
+
}
|
|
1983
|
+
/* On narrow viewports the hint hides — agent + budget pills stay,
|
|
1984
|
+
keyboard hint becomes discoverable via tooltip on the textarea. */
|
|
1985
|
+
@media (max-width: 720px) {
|
|
1986
|
+
.composer-meta .cm-hint { display: none; }
|
|
1987
|
+
}
|
|
1988
|
+
|
|
850
1989
|
.intake-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
|
|
851
1990
|
.intake select, .intake button, .intake input.cwd {
|
|
852
1991
|
font-family: var(--mono); font-size: 12px;
|
|
@@ -1005,7 +2144,7 @@ ${SHARED_STYLES}
|
|
|
1005
2144
|
}
|
|
1006
2145
|
.session-head {
|
|
1007
2146
|
display: flex; align-items: center; justify-content: space-between;
|
|
1008
|
-
padding:
|
|
2147
|
+
padding: 14px 20px;
|
|
1009
2148
|
cursor: pointer;
|
|
1010
2149
|
user-select: none;
|
|
1011
2150
|
}
|
|
@@ -1023,20 +2162,95 @@ ${SHARED_STYLES}
|
|
|
1023
2162
|
.session-head .meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
1024
2163
|
.session-head .prompt { font-family: var(--mono); font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 620px; }
|
|
1025
2164
|
.session-head .sub { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
|
|
2165
|
+
/* May 6 2026 design polish (Tier 1 #2): drop uppercase + heavy letter-spacing
|
|
2166
|
+
per Open-Agents production-grade reference. Status pills now read as
|
|
2167
|
+
human labels (Sentence case) not all-caps shouts. Killed demoted from
|
|
2168
|
+
yellow-warn to muted neutral — it's just "stopped", not an alert. */
|
|
1026
2169
|
.session-head .status-pill {
|
|
1027
|
-
font-family: var(--mono); font-size:
|
|
1028
|
-
text-transform: uppercase; letter-spacing: 0.08em;
|
|
2170
|
+
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
|
|
1029
2171
|
padding: 3px 10px; border-radius: 999px;
|
|
1030
2172
|
border: 1px solid transparent;
|
|
1031
2173
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
.status-pill.
|
|
2174
|
+
/* Pattern 8 · Status badge taxonomy — locked palette per Track A #2.
|
|
2175
|
+
7 owned states; semantic colors reflect actionable meaning.
|
|
2176
|
+
Per docs/design/AGENTIC_UX.md Pattern 8. */
|
|
2177
|
+
.status-pill.running { color: var(--accent); background: var(--accent-wash); border-color: rgba(255,154,118,0.25); }
|
|
2178
|
+
.status-pill.completed { color: var(--allow); background: rgba(40,200,64,0.10); border-color: rgba(40,200,64,0.20); }
|
|
2179
|
+
.status-pill.awaiting { color: var(--accent); background: transparent; border-color: var(--accent); border-style: dashed; }
|
|
2180
|
+
.status-pill.blocked { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
|
|
2181
|
+
.status-pill.failed,
|
|
2182
|
+
.status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2183
|
+
.status-pill.killed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
|
|
1036
2184
|
.status-pill.observed-pill { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); font-weight: 400; }
|
|
1037
2185
|
|
|
2186
|
+
/* Pattern 6 · macro states — wired to actual adapter error codes
|
|
2187
|
+
emitted by failover-classifier.ts + per-adapter ERROR_PATTERNS.
|
|
2188
|
+
yellow (--warn) = recoverable (failover-eligible per failover-classifier);
|
|
2189
|
+
red (--block) = needs human action (auth, billing, model-pick).
|
|
2190
|
+
Per docs/design/AGENTIC_UX.md Pattern 6. */
|
|
2191
|
+
.status-pill.rate-limit,
|
|
2192
|
+
.status-pill.quota-exceeded,
|
|
2193
|
+
.status-pill.credit-low { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
|
|
2194
|
+
.status-pill.auth-invalid,
|
|
2195
|
+
.status-pill.auth-missing,
|
|
2196
|
+
.status-pill.model-unavailable,
|
|
2197
|
+
.status-pill.crashed { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
|
|
2198
|
+
.status-pill.thinking,
|
|
2199
|
+
.status-pill.running-buffered { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); }
|
|
2200
|
+
|
|
2201
|
+
/* Persisted sessions = your own past tasks replayed from disk.
|
|
2202
|
+
Faded but VISIBLE. The detected-process variant (.observed) is
|
|
2203
|
+
diagnostic — hidden by default per claim_user_persona. */
|
|
2204
|
+
.session.persisted { opacity: 0.88; }
|
|
2205
|
+
.session.persisted .session-head { border-bottom: none; }
|
|
1038
2206
|
.session.observed { opacity: 0.88; }
|
|
1039
2207
|
.session.observed .session-head { border-bottom: none; }
|
|
2208
|
+
/* claim_user_persona HIGH — detected-process rows ("Claude Code ·
|
|
2209
|
+
pid 30531") are diagnostic, not user-facing. Hidden by default;
|
|
2210
|
+
advanced toggle reveals. The user's OWN spawned + persisted
|
|
2211
|
+
sessions still show — only externally-detected runners are gated. */
|
|
2212
|
+
.session.observed { display: none; }
|
|
2213
|
+
body.advanced-on .session.observed { display: block; }
|
|
2214
|
+
|
|
2215
|
+
/* claim_user_persona — session list limit. Default shows the 5
|
|
2216
|
+
most-recent cards; "Show all" reveals the rest. .show-all-on body
|
|
2217
|
+
class lifts the cap. CSS-only via :nth-child so attachSession
|
|
2218
|
+
prepend semantics stay simple.
|
|
2219
|
+
CRITICAL: running sessions are NEVER capped — a user must always
|
|
2220
|
+
see their actively-working session, even if it's the 10th card. */
|
|
2221
|
+
#sessions > .session:nth-child(n+6):not(.status-running) { display: none; }
|
|
2222
|
+
body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
|
|
2223
|
+
/* When advanced is also off, observed rows beyond cap stay hidden. */
|
|
2224
|
+
body.show-all-sessions #sessions > .session.observed { display: none; }
|
|
2225
|
+
body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
|
|
2226
|
+
.sessions-actions {
|
|
2227
|
+
display: flex; gap: 10px; justify-content: center; align-items: center;
|
|
2228
|
+
margin: 14px 0 0;
|
|
2229
|
+
}
|
|
2230
|
+
.show-more-sessions {
|
|
2231
|
+
display: none; /* shown via JS when count > 5 */
|
|
2232
|
+
padding: 8px 16px;
|
|
2233
|
+
background: transparent;
|
|
2234
|
+
color: var(--fg-50);
|
|
2235
|
+
border: 1px solid var(--border);
|
|
2236
|
+
border-radius: 999px;
|
|
2237
|
+
font-family: var(--mono); font-size: 11px;
|
|
2238
|
+
cursor: pointer;
|
|
2239
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
2240
|
+
}
|
|
2241
|
+
.show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2242
|
+
body.show-all-sessions .show-more-sessions { display: none; }
|
|
2243
|
+
.stop-all-sessions {
|
|
2244
|
+
padding: 8px 16px;
|
|
2245
|
+
background: transparent;
|
|
2246
|
+
color: var(--block);
|
|
2247
|
+
border: 1px solid rgba(255,95,87,0.3);
|
|
2248
|
+
border-radius: 999px;
|
|
2249
|
+
font-family: var(--mono); font-size: 11px;
|
|
2250
|
+
cursor: pointer;
|
|
2251
|
+
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
|
2252
|
+
}
|
|
2253
|
+
.stop-all-sessions:hover { color: var(--fg); border-color: var(--block); background: rgba(255,95,87,0.08); }
|
|
1040
2254
|
|
|
1041
2255
|
.session-controls { display: flex; gap: 8px; }
|
|
1042
2256
|
.session-controls button {
|
|
@@ -1047,8 +2261,172 @@ ${SHARED_STYLES}
|
|
|
1047
2261
|
}
|
|
1048
2262
|
.session-controls button:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
1049
2263
|
.session-controls button.kill:hover { color: var(--block); border-color: rgba(255,95,87,0.4); }
|
|
2264
|
+
/* Pattern 19 — interrupt affordance state machine (ready / confirming / killing).
|
|
2265
|
+
Confirming state turns the button red so the second press registers
|
|
2266
|
+
as intentional. 3s window then reverts to ready. */
|
|
2267
|
+
.session-controls button.kill[data-state="confirming"] {
|
|
2268
|
+
color: var(--block);
|
|
2269
|
+
border-color: var(--block);
|
|
2270
|
+
background: rgba(255,95,87,0.08);
|
|
2271
|
+
font-weight: 600;
|
|
2272
|
+
animation: kill-confirm-pulse 0.6s ease-in-out infinite alternate;
|
|
2273
|
+
}
|
|
2274
|
+
.session-controls button.kill[data-state="killing"] {
|
|
2275
|
+
color: var(--fg-50);
|
|
2276
|
+
cursor: progress;
|
|
2277
|
+
opacity: 0.7;
|
|
2278
|
+
}
|
|
2279
|
+
@keyframes kill-confirm-pulse {
|
|
2280
|
+
from { box-shadow: 0 0 0 rgba(255,95,87,0); }
|
|
2281
|
+
to { box-shadow: 0 0 8px rgba(255,95,87,0.4); }
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
.session-body { padding: 0 20px 16px; }
|
|
2285
|
+
|
|
2286
|
+
/* Session threading — visual link between original and failover sessions.
|
|
2287
|
+
The original card shows "↻ continued as [agent]" with a link to the new card.
|
|
2288
|
+
The new card shows "Continued from [agent]" at the top. */
|
|
2289
|
+
.session-continued-as {
|
|
2290
|
+
margin: 0 20px 12px;
|
|
2291
|
+
padding: 8px 12px;
|
|
2292
|
+
border: 1px dashed rgba(255,154,118,0.3);
|
|
2293
|
+
border-radius: 8px;
|
|
2294
|
+
background: rgba(255,154,118,0.04);
|
|
2295
|
+
font-family: var(--mono);
|
|
2296
|
+
font-size: 11px;
|
|
2297
|
+
color: var(--fg-70);
|
|
2298
|
+
}
|
|
2299
|
+
.session-continued-as a {
|
|
2300
|
+
color: var(--accent);
|
|
2301
|
+
text-decoration: none;
|
|
2302
|
+
font-weight: 500;
|
|
2303
|
+
}
|
|
2304
|
+
.session-continued-as a:hover { text-decoration: underline; }
|
|
2305
|
+
.session-continued-from {
|
|
2306
|
+
margin: 0 0 8px;
|
|
2307
|
+
padding: 6px 12px;
|
|
2308
|
+
border-left: 2px solid rgba(255,154,118,0.3);
|
|
2309
|
+
font-family: var(--mono);
|
|
2310
|
+
font-size: 10.5px;
|
|
2311
|
+
color: var(--fg-50);
|
|
2312
|
+
}
|
|
2313
|
+
.session-continued-from a {
|
|
2314
|
+
color: var(--accent);
|
|
2315
|
+
text-decoration: none;
|
|
2316
|
+
}
|
|
2317
|
+
.session-continued-from a:hover { text-decoration: underline; }
|
|
2318
|
+
|
|
2319
|
+
/* P18 slice 4 — permission gate panel. Renders inside the session
|
|
2320
|
+
card when the per-tool hook blocked something. Per the dogfood-
|
|
2321
|
+
learning + overconfidence-guard: framed as "Codehere noticed",
|
|
2322
|
+
not "security violation". Approve button is the most prominent
|
|
2323
|
+
because false-positives are the common case (e.g. /etc/shadow
|
|
2324
|
+
in a docstring). Keep-blocking is the rare-but-real branch. */
|
|
2325
|
+
.permission-gate {
|
|
2326
|
+
margin: 10px 0 8px;
|
|
2327
|
+
padding: 12px 14px;
|
|
2328
|
+
border: 1px solid rgba(254,188,46,0.35);
|
|
2329
|
+
background: rgba(254,188,46,0.05);
|
|
2330
|
+
border-radius: 8px;
|
|
2331
|
+
}
|
|
2332
|
+
.permission-gate .pg-label {
|
|
2333
|
+
font-family: var(--mono); font-size: 9.5px;
|
|
2334
|
+
color: var(--warn);
|
|
2335
|
+
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2336
|
+
margin-bottom: 4px;
|
|
2337
|
+
}
|
|
2338
|
+
.permission-gate .pg-title {
|
|
2339
|
+
font-family: var(--sans); font-size: 13px;
|
|
2340
|
+
color: var(--fg);
|
|
2341
|
+
margin-bottom: 6px;
|
|
2342
|
+
}
|
|
2343
|
+
.permission-gate .pg-detail {
|
|
2344
|
+
font-family: var(--mono); font-size: 11px;
|
|
2345
|
+
color: var(--fg-70);
|
|
2346
|
+
background: rgba(0,0,0,0.2);
|
|
2347
|
+
padding: 6px 10px; border-radius: 4px;
|
|
2348
|
+
margin-bottom: 10px;
|
|
2349
|
+
word-break: break-all;
|
|
2350
|
+
}
|
|
2351
|
+
.permission-gate .pg-actions {
|
|
2352
|
+
display: flex; gap: 8px; align-items: center;
|
|
2353
|
+
}
|
|
2354
|
+
.permission-gate button {
|
|
2355
|
+
font-family: var(--mono); font-size: 11px;
|
|
2356
|
+
padding: 6px 12px; border-radius: 6px;
|
|
2357
|
+
cursor: pointer;
|
|
2358
|
+
border: 1px solid var(--border);
|
|
2359
|
+
background: transparent;
|
|
2360
|
+
color: var(--fg-70);
|
|
2361
|
+
}
|
|
2362
|
+
.permission-gate button.pg-approve {
|
|
2363
|
+
color: var(--accent);
|
|
2364
|
+
border-color: var(--accent);
|
|
2365
|
+
background: var(--accent-wash);
|
|
2366
|
+
font-weight: 600;
|
|
2367
|
+
}
|
|
2368
|
+
.permission-gate button.pg-approve:hover { filter: brightness(1.1); }
|
|
2369
|
+
.permission-gate button.pg-keep:hover { color: var(--fg); border-color: var(--fg-30); }
|
|
2370
|
+
.permission-gate.approved {
|
|
2371
|
+
border-color: rgba(40,200,64,0.3);
|
|
2372
|
+
background: rgba(40,200,64,0.05);
|
|
2373
|
+
}
|
|
2374
|
+
.permission-gate.approved .pg-label { color: var(--allow); }
|
|
2375
|
+
.permission-gate.approved .pg-actions { display: none; }
|
|
2376
|
+
.permission-gate .pg-status {
|
|
2377
|
+
display: none;
|
|
2378
|
+
font-family: var(--mono); font-size: 10.5px;
|
|
2379
|
+
color: var(--allow);
|
|
2380
|
+
margin-top: 2px;
|
|
2381
|
+
}
|
|
2382
|
+
.permission-gate.approved .pg-status { display: block; }
|
|
2383
|
+
|
|
2384
|
+
/* v0.6 M3 — PM narrator block. Quiet style — visible but doesn't
|
|
2385
|
+
compete with primary content. Italic body to signal "this is
|
|
2386
|
+
narration, not authoritative" per overconfidence-guard. */
|
|
2387
|
+
.narrator-block {
|
|
2388
|
+
margin: 10px 0 8px;
|
|
2389
|
+
padding: 10px 14px;
|
|
2390
|
+
border-left: 2px solid var(--accent);
|
|
2391
|
+
background: rgba(255,154,118,0.04);
|
|
2392
|
+
border-radius: 0 8px 8px 0;
|
|
2393
|
+
}
|
|
2394
|
+
.narrator-block .narrator-label {
|
|
2395
|
+
font-family: var(--mono); font-size: 9.5px;
|
|
2396
|
+
color: var(--fg-30);
|
|
2397
|
+
text-transform: uppercase; letter-spacing: 0.14em;
|
|
2398
|
+
margin-bottom: 4px;
|
|
2399
|
+
}
|
|
2400
|
+
.narrator-block .narrator-text {
|
|
2401
|
+
font-family: var(--sans);
|
|
2402
|
+
font-size: 13px; line-height: 1.45;
|
|
2403
|
+
color: var(--fg-70);
|
|
2404
|
+
font-style: italic;
|
|
2405
|
+
}
|
|
2406
|
+
.narrator-block .narrator-meta {
|
|
2407
|
+
font-family: var(--mono); font-size: 10px;
|
|
2408
|
+
color: var(--fg-30);
|
|
2409
|
+
margin-top: 4px;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/* Pattern 3 · Session card footer — elapsed · tools · files. Renders
|
|
2413
|
+
beneath the body when the session has at least one step. Closes the
|
|
2414
|
+
card visually + surfaces the same counts as the live tool-progress
|
|
2415
|
+
chip but in a settled, summary form. Cost intentionally absent here
|
|
2416
|
+
— that's P5's composer surface, not the per-card footer (per
|
|
2417
|
+
AGENTIC_UX.md Pattern 3: "elapsed · cost · tool count · file count"
|
|
2418
|
+
with cost-router not yet wired). */
|
|
2419
|
+
.session-foot {
|
|
2420
|
+
display: flex; gap: 14px; align-items: center;
|
|
2421
|
+
padding: 10px 20px 12px;
|
|
2422
|
+
border-top: 1px solid var(--border-soft);
|
|
2423
|
+
font-family: var(--mono); font-size: 11px; color: var(--fg-50);
|
|
2424
|
+
}
|
|
2425
|
+
.session-foot[data-empty="true"] { display: none; }
|
|
2426
|
+
.session-foot .sf-item { display: inline-flex; gap: 4px; align-items: center; }
|
|
2427
|
+
.session-foot .sf-item b { color: var(--fg-70); font-weight: 600; }
|
|
2428
|
+
.session-foot .sf-sep { color: var(--fg-20); user-select: none; }
|
|
1050
2429
|
|
|
1051
|
-
.session-body { padding: 0 16px 14px; }
|
|
1052
2430
|
.event-log {
|
|
1053
2431
|
margin: 10px 0 0;
|
|
1054
2432
|
max-height: 360px; overflow-y: auto;
|
|
@@ -1212,31 +2590,80 @@ ${SHARED_STYLES}
|
|
|
1212
2590
|
<a href="/" class="nav-link active">Tasks</a>
|
|
1213
2591
|
<a href="/threads" class="nav-link">Threads</a>
|
|
1214
2592
|
<a href="/history" class="nav-link">History</a>
|
|
2593
|
+
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
2594
|
+
<span class="mode-indicator" data-mode="" id="mode-indicator" title="Loading safety-hook mode…"><span class="mi-dot"></span><span class="mi-label">…</span></span>
|
|
1215
2595
|
</nav>
|
|
1216
2596
|
|
|
1217
2597
|
<div class="shell slack-shape">
|
|
1218
2598
|
|
|
1219
2599
|
<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 -->
|
|
2600
|
+
<div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">0</span></div>
|
|
2601
|
+
<div id="channels-list"><div class="pane-empty">Loading…</div></div>
|
|
1231
2602
|
</aside>
|
|
1232
2603
|
|
|
1233
2604
|
<section class="thread-pane">
|
|
1234
2605
|
|
|
1235
|
-
|
|
2606
|
+
<!-- v0.6 S2 — first-time onboarding card. Hidden once the user
|
|
2607
|
+
dismisses (localStorage flag codehere.onboarded=1) OR once any
|
|
2608
|
+
session exists in the dashboard. Per claim_user_persona HIGH:
|
|
2609
|
+
non-technical users primary; the first 5 minutes is load-bearing
|
|
2610
|
+
(feedback_first_5_minutes_is_load_bearing.md). This card guides
|
|
2611
|
+
those 5 minutes: pick an agent → type a task → watch the report. -->
|
|
2612
|
+
<div class="onboarding-card" id="onboarding-card" style="display:none">
|
|
2613
|
+
<div class="ob-head">
|
|
2614
|
+
<div class="ob-title">Welcome to codehere.</div>
|
|
2615
|
+
<button type="button" class="ob-close" id="ob-close" aria-label="Dismiss">×</button>
|
|
2616
|
+
</div>
|
|
2617
|
+
<div class="ob-blurb">A PM for your AI coding agents. Delegate a task in plain English; codehere picks an agent, watches every action, and reports back.</div>
|
|
2618
|
+
<ol class="ob-steps">
|
|
2619
|
+
<li>
|
|
2620
|
+
<span class="ob-num">1</span>
|
|
2621
|
+
<div>
|
|
2622
|
+
<div class="ob-step-title">Pick an agent — or let codehere pick</div>
|
|
2623
|
+
<div class="ob-step-body">Auto-pick is the default and works for most tasks. Specialists (Claude Code, Codex, Aider, Gemini, OpenCode) are below.</div>
|
|
2624
|
+
</div>
|
|
2625
|
+
</li>
|
|
2626
|
+
<li>
|
|
2627
|
+
<span class="ob-num">2</span>
|
|
2628
|
+
<div>
|
|
2629
|
+
<div class="ob-step-title">Type what needs doing</div>
|
|
2630
|
+
<div class="ob-step-body">Plain English. Try: "Show me what this repo does in 3 bullets" or "Run the tests and tell me what failed."</div>
|
|
2631
|
+
</div>
|
|
2632
|
+
</li>
|
|
2633
|
+
<li>
|
|
2634
|
+
<span class="ob-num">3</span>
|
|
2635
|
+
<div>
|
|
2636
|
+
<div class="ob-step-title">Watch the Final Report</div>
|
|
2637
|
+
<div class="ob-step-body">When the agent finishes, codehere generates a plain-English summary of what changed, what was tested, and what to look at next.</div>
|
|
2638
|
+
</div>
|
|
2639
|
+
</li>
|
|
2640
|
+
</ol>
|
|
2641
|
+
</div>
|
|
1236
2642
|
|
|
1237
2643
|
<div class="intake">
|
|
1238
|
-
<h1>
|
|
1239
|
-
<
|
|
2644
|
+
<h1>Delegate coding tasks to any AI agent.</h1>
|
|
2645
|
+
<p class="sub">Type what needs doing — codehere picks the right agent, watches every action, and reports back.</p>
|
|
2646
|
+
<textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
|
|
2647
|
+
<!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
|
|
2648
|
+
dropped the cm-cwd pill — the cwd input below is the authoritative
|
|
2649
|
+
editable surface; duplicating it as a non-editable pill caused
|
|
2650
|
+
desync (pill stayed "—" while input showed the daemon-restored
|
|
2651
|
+
cwd). Folded the keyboard-shortcut hint inline-left so the prior
|
|
2652
|
+
second .intake-row could be deleted. -->
|
|
2653
|
+
<div class="composer-meta" id="composer-meta">
|
|
2654
|
+
<span class="cm-pill cm-agent" id="cm-agent"><b>Auto</b></span>
|
|
2655
|
+
<span class="cm-sep">·</span>
|
|
2656
|
+
<span class="cm-pill cm-budget" id="cm-budget" data-state="ok"><b>0%</b><span>used</span></span>
|
|
2657
|
+
<span class="cm-sep">·</span>
|
|
2658
|
+
<!-- v0.6 S3 slice 1 — today's estimated spend across every completed
|
|
2659
|
+
session in ~/.codehere/sessions.jsonl. Hidden when zero so a
|
|
2660
|
+
fresh-install dashboard isn't cluttered with "$0.00 today". Tooltip
|
|
2661
|
+
points to vendor dashboards as the authoritative number. -->
|
|
2662
|
+
<span class="cm-pill cm-spend" id="cm-spend" style="display:none" title="Estimated. Vendor dashboards are authoritative."><b>$0.00</b><span>today</span></span>
|
|
2663
|
+
<span class="cm-hint">⌘+Enter to start · ⌘+Shift+Enter to reply to last task</span>
|
|
2664
|
+
<span class="cm-spacer"></span>
|
|
2665
|
+
<span class="cm-pill cm-cost" id="cm-cost" style="display:none"></span>
|
|
2666
|
+
</div>
|
|
1240
2667
|
<div class="starter-prompts" id="starter-prompts">
|
|
1241
2668
|
<span class="sp-label">TRY</span>
|
|
1242
2669
|
<button type="button" data-prompt="Read the README and summarize what this project is in 3 short bullets.">Summarize this repo</button>
|
|
@@ -1244,15 +2671,12 @@ ${SHARED_STYLES}
|
|
|
1244
2671
|
<button type="button" data-prompt="Run the tests and tell me what (if anything) is failing.">Run the tests</button>
|
|
1245
2672
|
</div>
|
|
1246
2673
|
<div class="intake-row">
|
|
1247
|
-
<select id="agent" title="
|
|
2674
|
+
<select id="agent" title="codehere picks the right tool unless you override"><option value="">Auto-pick</option></select>
|
|
1248
2675
|
<input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
|
|
1249
2676
|
<button class="continue-thread" id="continue-thread" type="button" disabled title="Send as a reply to the most recent task in this folder (Slack-shape spike)">Continue thread</button>
|
|
1250
2677
|
<button class="spawn" id="spawn">Assign new</button>
|
|
1251
2678
|
</div>
|
|
1252
2679
|
<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
2680
|
</div>
|
|
1257
2681
|
|
|
1258
2682
|
<!-- Apr 29 — UX overhaul: readiness banner + stats demoted below intake.
|
|
@@ -1260,22 +2684,37 @@ ${SHARED_STYLES}
|
|
|
1260
2684
|
Goal: textarea is the focal point above the fold; chrome subordinates. -->
|
|
1261
2685
|
<div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
|
|
1262
2686
|
|
|
2687
|
+
<!-- v0.6.x P0 — failover offer banner. Surfaces when an adapter hits
|
|
2688
|
+
rate-limit / quota so the user can manually switch agents instead
|
|
2689
|
+
of waiting for auto-failover. Hidden by default; shown via JS when
|
|
2690
|
+
offers arrive from /api/failover-offers. -->
|
|
2691
|
+
<div class="failover-banner" id="failover-banner" style="display:none"></div>
|
|
2692
|
+
|
|
2693
|
+
<!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
|
|
2694
|
+
The prior 4-stat row mixed two definitions of "agents" (the AGENTS
|
|
2695
|
+
right-pane shows installed-and-ready agents; "agents on machine" stat
|
|
2696
|
+
showed F1d-detected running processes — empty in practice) producing
|
|
2697
|
+
a contradiction visible to users (e.g. "0 agents on machine" while the
|
|
2698
|
+
right pane shows 6). Plus "blocked" was almost always 0 and "avg trust
|
|
2699
|
+
100" had no signal. Per Open-Agents production-grade reference and
|
|
2700
|
+
claim_user_persona (HIGH non-technical-first): subtract dashboard noise.
|
|
2701
|
+
Keep only the one stat that varies meaningfully — actions today —
|
|
2702
|
+
rendered as a quiet muted line. The other IDs stay in the DOM (hidden)
|
|
2703
|
+
so existing setText calls in updateBanner don't NPE. -->
|
|
1263
2704
|
<div class="stats-line empty" id="audit-summary" style="position:relative;z-index:1">
|
|
1264
2705
|
<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
2706
|
</div>
|
|
1274
2707
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
2708
|
+
<!-- UX-simplification iter 3 (May 7): dropped the .glow-line peach
|
|
2709
|
+
divider here. The .section-label "Sessions" below already provides
|
|
2710
|
+
visual separation; an extra 1px gradient line was decoration not
|
|
2711
|
+
information. -->
|
|
2712
|
+
<div class="section-label" style="position:relative;z-index:1">Sessions</div>
|
|
2713
|
+
<div id="sessions" style="position:relative;z-index:1"></div>
|
|
2714
|
+
<div class="sessions-actions" id="sessions-actions">
|
|
2715
|
+
<button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
|
|
2716
|
+
<button class="stop-all-sessions" id="stop-all-sessions" type="button" style="display:none">Stop all running</button>
|
|
2717
|
+
</div>
|
|
1279
2718
|
<div id="empty">
|
|
1280
2719
|
<div class="pill">No sessions yet. Assign one above.</div>
|
|
1281
2720
|
</div>
|
|
@@ -1301,6 +2740,170 @@ ${SHARED_STYLES}
|
|
|
1301
2740
|
const emptyEl = document.getElementById('empty');
|
|
1302
2741
|
const sessions = new Map(); // sessionId → { card, logEl, unsubscribeSse }
|
|
1303
2742
|
|
|
2743
|
+
// v0.6 S2 — first-time onboarding card. Show ONLY when:
|
|
2744
|
+
// (a) localStorage flag codehere.onboarded is unset, AND
|
|
2745
|
+
// (b) no sessions yet (empty-state).
|
|
2746
|
+
// Hide when user dismisses (sets flag) OR when first session attaches.
|
|
2747
|
+
var obCard = document.getElementById('onboarding-card');
|
|
2748
|
+
var obClose = document.getElementById('ob-close');
|
|
2749
|
+
var ONBOARDED_KEY = 'codehere.onboarded';
|
|
2750
|
+
function isOnboarded() {
|
|
2751
|
+
try { return window.localStorage.getItem(ONBOARDED_KEY) === '1'; }
|
|
2752
|
+
catch (_) { return false; }
|
|
2753
|
+
}
|
|
2754
|
+
function markOnboarded() {
|
|
2755
|
+
try { window.localStorage.setItem(ONBOARDED_KEY, '1'); }
|
|
2756
|
+
catch (_) { /* private mode: persist for the session via the var below */ }
|
|
2757
|
+
}
|
|
2758
|
+
function maybeShowOnboarding() {
|
|
2759
|
+
if (!obCard) return;
|
|
2760
|
+
if (isOnboarded()) { obCard.style.display = 'none'; return; }
|
|
2761
|
+
obCard.style.display = '';
|
|
2762
|
+
}
|
|
2763
|
+
function dismissOnboarding() {
|
|
2764
|
+
if (!obCard) return;
|
|
2765
|
+
obCard.style.display = 'none';
|
|
2766
|
+
markOnboarded();
|
|
2767
|
+
}
|
|
2768
|
+
if (obClose) obClose.addEventListener('click', dismissOnboarding);
|
|
2769
|
+
// Render decision runs immediately + after first sessions fetch
|
|
2770
|
+
// (which sets sessions Map). Hide if any sessions already exist
|
|
2771
|
+
// (returning user — onboarding doesn't help).
|
|
2772
|
+
maybeShowOnboarding();
|
|
2773
|
+
|
|
2774
|
+
// Pattern 5 — composer meta line wiring. Live-updates: agent label,
|
|
2775
|
+
// context-budget %, cost preview. Bound below to the input surfaces
|
|
2776
|
+
// (prompt textarea, agent select). UX-simplification iter (May 7):
|
|
2777
|
+
// dropped cm-cwd pill — cwd input below is the authoritative surface.
|
|
2778
|
+
const cmAgentEl = document.getElementById('cm-agent');
|
|
2779
|
+
const cmBudgetEl = document.getElementById('cm-budget');
|
|
2780
|
+
const cmCostEl = document.getElementById('cm-cost');
|
|
2781
|
+
// v0.6 S3 slice 1 — today's spend chip. Lives next to the context-budget
|
|
2782
|
+
// chip; hidden until the first non-zero spend lands so a fresh-install
|
|
2783
|
+
// dashboard isn't cluttered. Polled, not pushed (events would need
|
|
2784
|
+
// server-side fanout).
|
|
2785
|
+
const cmSpendEl = document.getElementById('cm-spend');
|
|
2786
|
+
function refreshSpendToday() {
|
|
2787
|
+
if (!cmSpendEl) return;
|
|
2788
|
+
fetch('/api/spend/today')
|
|
2789
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
2790
|
+
.then(function(d) {
|
|
2791
|
+
if (!d || typeof d.totalUsd !== 'number') return;
|
|
2792
|
+
// Show the chip when EITHER spend > 0 OR a budget is set (so the
|
|
2793
|
+
// user sees "$0.00 / $5.00" right after \`codehere budget set 5\`).
|
|
2794
|
+
var budget = d.budget || {};
|
|
2795
|
+
var hasBudget = budget.dailyUsd !== null && typeof budget.dailyUsd === 'number';
|
|
2796
|
+
if (d.totalUsd <= 0 && d.sessionCount === 0 && !hasBudget) {
|
|
2797
|
+
cmSpendEl.style.display = 'none';
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
cmSpendEl.style.display = 'inline-flex';
|
|
2801
|
+
var b = cmSpendEl.querySelector('b');
|
|
2802
|
+
if (b) {
|
|
2803
|
+
var dollars = d.totalUsd;
|
|
2804
|
+
var spendStr = dollars >= 0.01 ? '$' + dollars.toFixed(2) : '$' + dollars.toFixed(3);
|
|
2805
|
+
// When a budget is set, render "$X.XX / $Y" so the ceiling is visible.
|
|
2806
|
+
b.textContent = hasBudget
|
|
2807
|
+
? spendStr + ' / $' + budget.dailyUsd.toFixed(2)
|
|
2808
|
+
: spendStr;
|
|
2809
|
+
}
|
|
2810
|
+
// Color state — warn at warnAtPct, block when over.
|
|
2811
|
+
var state = 'ok';
|
|
2812
|
+
if (hasBudget) {
|
|
2813
|
+
if (budget.over) state = 'block';
|
|
2814
|
+
else if (typeof budget.pctUsed === 'number' && budget.pctUsed >= (budget.warnAtPct || 70)) state = 'warn';
|
|
2815
|
+
}
|
|
2816
|
+
cmSpendEl.dataset.state = state;
|
|
2817
|
+
// Tooltip: per-agent breakdown + budget context if set.
|
|
2818
|
+
var lines = ['Estimated spend today across ' + d.sessionCount + ' completed session' + (d.sessionCount === 1 ? '' : 's') + ':'];
|
|
2819
|
+
var keys = Object.keys(d.byAgent || {});
|
|
2820
|
+
keys.sort(function(a, b) { return (d.byAgent[b].usd || 0) - (d.byAgent[a].usd || 0); });
|
|
2821
|
+
for (var i = 0; i < keys.length; i++) {
|
|
2822
|
+
var k = keys[i];
|
|
2823
|
+
var v = d.byAgent[k];
|
|
2824
|
+
lines.push(' ' + k + ': $' + (v.usd >= 0.01 ? v.usd.toFixed(2) : v.usd.toFixed(3)) + ' (' + v.sessions + 'x)');
|
|
2825
|
+
}
|
|
2826
|
+
if (hasBudget) {
|
|
2827
|
+
lines.push('');
|
|
2828
|
+
lines.push('Daily budget: $' + budget.dailyUsd.toFixed(2) + ' (' + (budget.pctUsed || 0) + '% used)');
|
|
2829
|
+
if (budget.over) {
|
|
2830
|
+
lines.push('OVER BUDGET — paid spawns will be refused. Use a free agent (gemini, opencode) or run: codehere budget set <higher-amount>');
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
lines.push('Vendor dashboards are authoritative — these are tier-ceiling estimates.');
|
|
2834
|
+
cmSpendEl.title = lines.join('\\n');
|
|
2835
|
+
})
|
|
2836
|
+
.catch(function() { /* skip */ });
|
|
2837
|
+
}
|
|
2838
|
+
// Refresh on dashboard load + every 60s (sessions complete async, no point
|
|
2839
|
+
// polling faster than per-minute).
|
|
2840
|
+
refreshSpendToday();
|
|
2841
|
+
setInterval(refreshSpendToday, 60_000);
|
|
2842
|
+
|
|
2843
|
+
// Free-tier agents per feedback_adapters_must_have_free_tier.md. Cost
|
|
2844
|
+
// preview hides for these (the user pays nothing per call). Conductor
|
|
2845
|
+
// routes to one of the specialists, so its cost mirrors the picked one.
|
|
2846
|
+
const FREE_TIER_AGENTS = new Set(['gemini', 'opencode']);
|
|
2847
|
+
|
|
2848
|
+
// Per-agent rough cost estimate ($ per 1K input + 1K output tokens combined).
|
|
2849
|
+
// These are CEILINGS for ballpark ("~$0.0X likely") not precise — adapter
|
|
2850
|
+
// doesn't surface model selection, so the heuristic uses a balanced-tier
|
|
2851
|
+
// assumption. Better to under-promise; users care about scale-of-magnitude.
|
|
2852
|
+
const COST_PER_KTOK = {
|
|
2853
|
+
'claude-code': 0.018, // sonnet-tier balanced
|
|
2854
|
+
'codex': 0.020, // gpt-5-codex balanced
|
|
2855
|
+
'aider': 0.018, // typically sonnet
|
|
2856
|
+
'conductor': 0.018, // routes to claude-code by default
|
|
2857
|
+
};
|
|
2858
|
+
|
|
2859
|
+
function syncComposerMeta() {
|
|
2860
|
+
if (!cmAgentEl || !cmBudgetEl) return;
|
|
2861
|
+
// Resolve agent label. Empty value = Conductor-Auto routing.
|
|
2862
|
+
var pickedId = (agentSelect && agentSelect.value) || '';
|
|
2863
|
+
var label = pickedId
|
|
2864
|
+
? agentLabel(pickedId)
|
|
2865
|
+
: 'Auto';
|
|
2866
|
+
cmAgentEl.querySelector('b').textContent = label;
|
|
2867
|
+
// Context budget — char-count / 800K (≈ 200K-token Sonnet budget).
|
|
2868
|
+
// Cap at 100. --warn at 70, --block at 90 per spec.
|
|
2869
|
+
var chars = (promptEl && promptEl.value && promptEl.value.length) || 0;
|
|
2870
|
+
var pct = Math.min(100, Math.round((chars / 8000)));
|
|
2871
|
+
var budgetState = pct >= 90 ? 'block' : pct >= 70 ? 'warn' : 'ok';
|
|
2872
|
+
cmBudgetEl.dataset.state = budgetState;
|
|
2873
|
+
cmBudgetEl.querySelector('b').textContent = pct + '%';
|
|
2874
|
+
// Cost preview (paid agents only). Auto resolves to the conductor, so
|
|
2875
|
+
// pick the cheapest balanced-tier estimate when no agent is set.
|
|
2876
|
+
if (!cmCostEl) return;
|
|
2877
|
+
var effective = pickedId || 'conductor';
|
|
2878
|
+
if (FREE_TIER_AGENTS.has(effective)) {
|
|
2879
|
+
cmCostEl.style.display = 'inline-flex';
|
|
2880
|
+
cmCostEl.className = 'cm-pill cm-cost free';
|
|
2881
|
+
cmCostEl.innerHTML = '<b>free tier</b>';
|
|
2882
|
+
cmCostEl.title = effective + ' has a free tier — no per-call billing';
|
|
2883
|
+
} else if (COST_PER_KTOK[effective] && chars > 20) {
|
|
2884
|
+
// Rough heuristic: input tokens ≈ chars/4, output ~2× input for code
|
|
2885
|
+
// tasks; clamp to a sensible floor. Show 2 decimals when > 0.01,
|
|
2886
|
+
// 3 decimals otherwise so 4¢ doesn't render as "$0.04".
|
|
2887
|
+
var inputTok = Math.ceil(chars / 4);
|
|
2888
|
+
var outputTok = Math.max(500, Math.min(3000, inputTok * 2));
|
|
2889
|
+
var totalK = (inputTok + outputTok) / 1000;
|
|
2890
|
+
var dollars = totalK * COST_PER_KTOK[effective];
|
|
2891
|
+
var dollarsStr = dollars >= 0.01
|
|
2892
|
+
? '~$' + (Math.round(dollars * 100) / 100).toFixed(2)
|
|
2893
|
+
: '~$' + (Math.round(dollars * 1000) / 1000).toFixed(3);
|
|
2894
|
+
cmCostEl.style.display = 'inline-flex';
|
|
2895
|
+
cmCostEl.className = 'cm-pill cm-cost paid';
|
|
2896
|
+
cmCostEl.innerHTML = '<b>' + dollarsStr + '</b><span> est.</span>';
|
|
2897
|
+
cmCostEl.title = 'Estimate: ' + inputTok + ' input + ~' + outputTok + ' output tokens at balanced-tier rates';
|
|
2898
|
+
} else {
|
|
2899
|
+
cmCostEl.style.display = 'none';
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
if (promptEl) promptEl.addEventListener('input', syncComposerMeta);
|
|
2903
|
+
if (agentSelect) agentSelect.addEventListener('change', syncComposerMeta);
|
|
2904
|
+
// Initial paint.
|
|
2905
|
+
syncComposerMeta();
|
|
2906
|
+
|
|
1304
2907
|
// E3 · Populate agent dropdown from per-agent readiness.
|
|
1305
2908
|
// Setup-aware: shows every registered agent with a state glyph (not just
|
|
1306
2909
|
// installed ones). Selecting a non-ready agent unfolds the setup panel with
|
|
@@ -1331,14 +2934,30 @@ ${SHARED_STYLES}
|
|
|
1331
2934
|
const sorted = [...agentReadiness].sort((a, b) => (weight[a.state] ?? 99) - (weight[b.state] ?? 99));
|
|
1332
2935
|
const autoState = autoReadiness ? autoReadiness.state : 'ready';
|
|
1333
2936
|
const autoGlyph = autoState === 'ready' ? '⚡' : (STATE_GLYPH[autoState] || '⚠');
|
|
1334
|
-
const autoLabel = autoState === 'ready' ? 'Auto
|
|
2937
|
+
const autoLabel = autoState === 'ready' ? 'Auto-pick (codehere picks for you)' : 'Auto-pick (install an agent first)';
|
|
1335
2938
|
const autoOption = '<option value="" selected>' + autoGlyph + ' ' + autoLabel + '</option>';
|
|
1336
|
-
const specialistOptions = sorted.map(a =>
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2939
|
+
const specialistOptions = sorted.map(a => {
|
|
2940
|
+
// v0.6 S5 — adapter description surfaces as the option's hover title so
|
|
2941
|
+
// non-technical users get a plain-English "when to use me" hint without
|
|
2942
|
+
// having to read docs. Falls back to label-only when adapter has none.
|
|
2943
|
+
const titleAttr = a.description
|
|
2944
|
+
? ' title="' + escape(a.description) + '"'
|
|
2945
|
+
: '';
|
|
2946
|
+
return '<option value="' + escape(a.id) + '"' + titleAttr + '>' +
|
|
2947
|
+
(STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
|
|
2948
|
+
'</option>';
|
|
2949
|
+
}).join('');
|
|
2950
|
+
// M5 slice 2 — fan-out option. Sends the same prompt to every
|
|
2951
|
+
// installed specialist in parallel via /api/task/batch. Per
|
|
2952
|
+
// claim_codehere_shape v5 + Decision 22 (every coding agent a
|
|
2953
|
+
// first-class member), cross-check spawning is the v1.0 value
|
|
2954
|
+
// prop. Hidden when fewer than 2 specialists are ready (no
|
|
2955
|
+
// parallel value with one). Capped at 5 by the batch endpoint.
|
|
2956
|
+
const readyCount = sorted.filter(a => a.state === 'ready').length;
|
|
2957
|
+
const fanOutOption = readyCount >= 2
|
|
2958
|
+
? '<option value="*">⚡⚡ Send to every installed agent (' + readyCount + ' in parallel)</option>'
|
|
2959
|
+
: '';
|
|
2960
|
+
agentSelect.innerHTML = autoOption + fanOutOption + specialistOptions;
|
|
1342
2961
|
// Trigger the panel render for whatever is selected first
|
|
1343
2962
|
updateSetupPanel();
|
|
1344
2963
|
}
|
|
@@ -1532,6 +3151,7 @@ ${SHARED_STYLES}
|
|
|
1532
3151
|
(a.state || 'unknown');
|
|
1533
3152
|
return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
|
|
1534
3153
|
'<span class="member-presence ' + cls + '">' + glyph + '</span>' +
|
|
3154
|
+
agentBadgeHTML(a.id) + ' ' +
|
|
1535
3155
|
'<span class="member-label">' + escape(a.label || a.id) + '</span>' +
|
|
1536
3156
|
'</div>';
|
|
1537
3157
|
}).join('');
|
|
@@ -1565,22 +3185,147 @@ ${SHARED_STYLES}
|
|
|
1565
3185
|
// daemon already knows where it was launched and that's usually the right cwd.
|
|
1566
3186
|
const savedCwd = localStorage.getItem('codehere:cwd');
|
|
1567
3187
|
if (savedCwd) cwdInput.value = savedCwd;
|
|
3188
|
+
|
|
3189
|
+
// Track daemon cwd for active-channel highlight in the projects sidebar.
|
|
3190
|
+
// Refreshed on every /api/readiness call; multi-project sidebar reads it.
|
|
3191
|
+
let daemonCwdForActive = null;
|
|
3192
|
+
|
|
1568
3193
|
fetch('/api/readiness').then(r => r.json()).then(r => {
|
|
1569
3194
|
const daemonCwd = r && r.detected && r.detected.daemonCwd;
|
|
1570
3195
|
if (daemonCwd) {
|
|
1571
3196
|
cwdInput.placeholder = daemonCwd;
|
|
1572
3197
|
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;
|
|
3198
|
+
daemonCwdForActive = daemonCwd;
|
|
3199
|
+
loadDashboardProjects(); // re-render sidebar now that we know which project is "active"
|
|
1582
3200
|
}
|
|
1583
3201
|
}).catch(() => { /* placeholder unchanged */ });
|
|
3202
|
+
|
|
3203
|
+
// Multi-project sidebar — bug A1 fix (May 5 2026). Pre-fix: sidebar showed
|
|
3204
|
+
// a single hardcoded "current" channel that was un-clickable; users hit it
|
|
3205
|
+
// expecting to switch projects. Post-fix: real /api/projects load, click
|
|
3206
|
+
// navigates to /threads?project=<slug> (where the actual thread surface
|
|
3207
|
+
// lives), pencil rename works inline, active state matches daemon cwd.
|
|
3208
|
+
function shortPathDash(p) {
|
|
3209
|
+
if (!p) return '';
|
|
3210
|
+
const home = (window.location && window.location.hostname) ? '~' : '~';
|
|
3211
|
+
if (p.length <= 32) return p;
|
|
3212
|
+
const parts = p.split('/').filter(Boolean);
|
|
3213
|
+
if (parts.length <= 2) return p;
|
|
3214
|
+
return '…/' + parts.slice(-2).join('/');
|
|
3215
|
+
}
|
|
3216
|
+
function escapeHtmlDash(s) {
|
|
3217
|
+
if (s == null) return '';
|
|
3218
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
3219
|
+
}
|
|
3220
|
+
function renderDashboardProjects(list) {
|
|
3221
|
+
const wrap = document.getElementById('channels-list');
|
|
3222
|
+
const countEl = document.getElementById('channels-count');
|
|
3223
|
+
if (!wrap) return;
|
|
3224
|
+
if (countEl) countEl.textContent = list.length;
|
|
3225
|
+
if (list.length === 0) {
|
|
3226
|
+
wrap.innerHTML = '<div class="pane-empty">No projects yet</div>';
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
wrap.innerHTML = list.map(p => {
|
|
3230
|
+
const isActive = (daemonCwdForActive && p.cwd === daemonCwdForActive) ? ' active' : '';
|
|
3231
|
+
const name = escapeHtmlDash(p.title || '(untitled)');
|
|
3232
|
+
const cwdHint = escapeHtmlDash(shortPathDash(p.cwd));
|
|
3233
|
+
// Pattern 4 — multi-agent presence stack. Up to 3 dots (most-recent
|
|
3234
|
+
// first, deduped). Empty span when no agents in the thread (older
|
|
3235
|
+
// pre-mirror projects). agentPresenceDotHTML lives in AGENT_BADGE_JS;
|
|
3236
|
+
// each dot is 6px, vendor-slot color, no letter.
|
|
3237
|
+
const agents = Array.isArray(p.recentAgents) ? p.recentAgents : (p.lastAgent ? [p.lastAgent] : []);
|
|
3238
|
+
const presenceStack = agents.length > 0
|
|
3239
|
+
? '<span class="presence-stack" title="Recent agents: ' + escapeHtmlDash(agents.join(', ')) + '">' +
|
|
3240
|
+
agents.map(a => agentPresenceDotHTML(a)).join('') +
|
|
3241
|
+
'</span>'
|
|
3242
|
+
: '<span class="presence-stack presence-stack-empty"></span>';
|
|
3243
|
+
return '<div class="channel' + isActive + '" data-slug="' + escapeHtmlDash(p.slug) + '" title="Open project thread">' +
|
|
3244
|
+
'<span class="channel-glyph">#</span>' +
|
|
3245
|
+
'<div style="flex:1;min-width:0">' +
|
|
3246
|
+
'<div class="channel-name">' + name + '</div>' +
|
|
3247
|
+
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
3248
|
+
'</div>' +
|
|
3249
|
+
presenceStack +
|
|
3250
|
+
'<button class="channel-rename" data-slug="' + escapeHtmlDash(p.slug) + '" title="Rename project" aria-label="Rename project"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg></button>' +
|
|
3251
|
+
'</div>';
|
|
3252
|
+
}).join('');
|
|
3253
|
+
wrap.querySelectorAll('.channel').forEach(el => {
|
|
3254
|
+
el.addEventListener('click', (ev) => {
|
|
3255
|
+
if (ev.target && ev.target.classList && ev.target.classList.contains('channel-rename')) return;
|
|
3256
|
+
const slug = el.getAttribute('data-slug');
|
|
3257
|
+
if (slug) window.location.href = '/threads?project=' + encodeURIComponent(slug);
|
|
3258
|
+
});
|
|
3259
|
+
});
|
|
3260
|
+
wrap.querySelectorAll('.channel-rename').forEach(btn => {
|
|
3261
|
+
btn.addEventListener('click', (ev) => {
|
|
3262
|
+
ev.stopPropagation();
|
|
3263
|
+
const slug = btn.getAttribute('data-slug');
|
|
3264
|
+
if (!slug) return;
|
|
3265
|
+
const channel = btn.closest('.channel');
|
|
3266
|
+
const project = list.find(p => p.slug === slug);
|
|
3267
|
+
const current = (project && project.title) || '';
|
|
3268
|
+
startInlineRename(channel, slug, current, () => loadDashboardProjects());
|
|
3269
|
+
});
|
|
3270
|
+
});
|
|
3271
|
+
}
|
|
3272
|
+
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
3273
|
+
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
3274
|
+
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
3275
|
+
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
3276
|
+
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
3277
|
+
if (!channelEl) return;
|
|
3278
|
+
const nameEl = channelEl.querySelector('.channel-name');
|
|
3279
|
+
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
3280
|
+
nameEl.dataset.editing = '1';
|
|
3281
|
+
const originalText = nameEl.textContent;
|
|
3282
|
+
const input = document.createElement('input');
|
|
3283
|
+
input.type = 'text';
|
|
3284
|
+
input.value = currentTitle;
|
|
3285
|
+
input.maxLength = 100;
|
|
3286
|
+
input.className = 'channel-rename-input';
|
|
3287
|
+
nameEl.textContent = '';
|
|
3288
|
+
nameEl.appendChild(input);
|
|
3289
|
+
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
3290
|
+
let cleanedUp = false;
|
|
3291
|
+
function cleanup(restoreText) {
|
|
3292
|
+
if (cleanedUp) return;
|
|
3293
|
+
cleanedUp = true;
|
|
3294
|
+
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
3295
|
+
nameEl.textContent = restoreText;
|
|
3296
|
+
delete nameEl.dataset.editing;
|
|
3297
|
+
}
|
|
3298
|
+
function commit() {
|
|
3299
|
+
if (cleanedUp) return;
|
|
3300
|
+
const trimmed = input.value.trim();
|
|
3301
|
+
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
3302
|
+
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
3303
|
+
method: 'POST',
|
|
3304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3305
|
+
body: JSON.stringify({ title: trimmed }),
|
|
3306
|
+
})
|
|
3307
|
+
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
3308
|
+
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
3309
|
+
.catch(() => {
|
|
3310
|
+
input.classList.remove('error');
|
|
3311
|
+
// Force reflow so animation re-fires
|
|
3312
|
+
void input.offsetWidth;
|
|
3313
|
+
input.classList.add('error');
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
input.addEventListener('keydown', (ev) => {
|
|
3317
|
+
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
3318
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
3319
|
+
});
|
|
3320
|
+
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
3321
|
+
}
|
|
3322
|
+
function loadDashboardProjects() {
|
|
3323
|
+
fetch('/api/projects')
|
|
3324
|
+
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
3325
|
+
.then(data => renderDashboardProjects(data.projects || []))
|
|
3326
|
+
.catch(() => { /* leave loading state */ });
|
|
3327
|
+
}
|
|
3328
|
+
loadDashboardProjects();
|
|
1584
3329
|
cwdInput.addEventListener('change', () => {
|
|
1585
3330
|
localStorage.setItem('codehere:cwd', cwdInput.value.trim());
|
|
1586
3331
|
});
|
|
@@ -1772,20 +3517,59 @@ ${SHARED_STYLES}
|
|
|
1772
3517
|
logEl.scrollTop = logEl.scrollHeight;
|
|
1773
3518
|
}
|
|
1774
3519
|
|
|
3520
|
+
// Status display labels: keep the underlying status string ('killed', 'error')
|
|
3521
|
+
// as the data-layer truth, but map to a calmer human label. May 6 2026 polish
|
|
3522
|
+
// per Open-Agents reference + claim_user_persona (non-technical readers
|
|
3523
|
+
// don't think of their work as "KILLED" — they think "I stopped it").
|
|
3524
|
+
// Pattern 6 · macro state → user-visible copy. Plain English; non-technical-
|
|
3525
|
+
// first per claim_user_persona HIGH. Each state's CSS class is the same
|
|
3526
|
+
// string with hyphens (e.g. status === 'rate_limit' → class="status-pill
|
|
3527
|
+
// rate-limit"); CSS classes can't contain underscores cleanly, so the
|
|
3528
|
+
// wire-format state has underscores and the class form has hyphens.
|
|
3529
|
+
// Per docs/design/AGENTIC_UX.md Pattern 6.
|
|
3530
|
+
var STATE_LABELS = {
|
|
3531
|
+
'thinking': 'Thinking',
|
|
3532
|
+
'running': 'Running',
|
|
3533
|
+
'running_buffered': 'Running (buffered)',
|
|
3534
|
+
'completed': 'Done',
|
|
3535
|
+
'awaiting': 'Waiting on you',
|
|
3536
|
+
'blocked': 'Blocked',
|
|
3537
|
+
'killed': 'Stopped',
|
|
3538
|
+
'crashed': 'Crashed',
|
|
3539
|
+
'error': 'Failed',
|
|
3540
|
+
'failed': 'Failed',
|
|
3541
|
+
'rate_limit': 'Rate-limited',
|
|
3542
|
+
'quota_exceeded': 'Quota exceeded',
|
|
3543
|
+
'credit_low': 'Credits low',
|
|
3544
|
+
'auth_invalid': 'Sign-in needed',
|
|
3545
|
+
'auth_missing': 'Sign-in needed',
|
|
3546
|
+
'model_unavailable':'Model unavailable'
|
|
3547
|
+
};
|
|
3548
|
+
function statusLabel(status) {
|
|
3549
|
+
if (!status) return 'Unknown';
|
|
3550
|
+
if (STATE_LABELS[status]) return STATE_LABELS[status];
|
|
3551
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
3552
|
+
}
|
|
3553
|
+
// Convert wire-format state ('rate_limit') to CSS class form ('rate-limit').
|
|
3554
|
+
function statusClass(status) {
|
|
3555
|
+
return String(status || '').replace(/_/g, '-');
|
|
3556
|
+
}
|
|
1775
3557
|
function updateStatus(pillEl, status) {
|
|
1776
|
-
pillEl.className = 'status-pill ' + status;
|
|
1777
|
-
pillEl.textContent = status;
|
|
3558
|
+
pillEl.className = 'status-pill ' + statusClass(status);
|
|
3559
|
+
pillEl.textContent = statusLabel(status);
|
|
1778
3560
|
}
|
|
1779
3561
|
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
3562
|
+
// Per-agent badge helpers (agentLabel, AGENT_SLOTS, AGENT_ICONS,
|
|
3563
|
+
// agentBadgeHTML, agentPresenceDotHTML) interpolated from AGENT_BADGE_JS
|
|
3564
|
+
// — single source of truth shared with THREAD_HTML and any other page
|
|
3565
|
+
// that needs vendor-attribution rendering. Per Pattern 10.
|
|
3566
|
+
${AGENT_BADGE_JS}
|
|
1784
3567
|
|
|
1785
3568
|
function attachSession(session) {
|
|
1786
3569
|
const card = document.createElement('div');
|
|
1787
3570
|
card.className = 'session';
|
|
1788
3571
|
card.dataset.sessionId = session.sessionId;
|
|
3572
|
+
card.dataset.status = session.status;
|
|
1789
3573
|
// C3 · Persisted sessions are from a prior daemon run — no live stream, no
|
|
1790
3574
|
// kill. Still useful: user sees their previous Final reports across restarts.
|
|
1791
3575
|
const isPersisted = session.provenance === 'persisted';
|
|
@@ -1795,17 +3579,20 @@ ${SHARED_STYLES}
|
|
|
1795
3579
|
'<div class="session-head">' +
|
|
1796
3580
|
'<div class="meta">' +
|
|
1797
3581
|
'<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
3582
|
escape(session.prompt) +
|
|
1800
3583
|
'<span class="chevron">▸</span>' +
|
|
1801
3584
|
'</div>' +
|
|
1802
|
-
'<div class="sub">' + liveDot +
|
|
3585
|
+
'<div class="sub">' + liveDot + agentBadgeHTML(session.agent) + ' ' + escape(agentLabel(session.agent)) +
|
|
3586
|
+
(session.cwd ? ' · <span class="sub-cwd" title="' + escape(session.cwd) + '">' + escape(cwdTail(session.cwd)) + '</span>' : '') +
|
|
3587
|
+
' · ' + new Date(session.startedAt).toLocaleTimeString() +
|
|
1803
3588
|
'<span class="safety-pill" style="display:none">' +
|
|
1804
3589
|
'<span class="shield">🛡</span> ' +
|
|
1805
3590
|
'<span class="safe">0</span> safe' +
|
|
1806
3591
|
'<span class="blocked-portion" style="display:none"> · <span class="blocked">0</span> blocked</span>' +
|
|
1807
3592
|
'</span>' +
|
|
1808
3593
|
'</div>' +
|
|
3594
|
+
'<div class="tool-progress-chip" data-state="hidden"></div>' +
|
|
3595
|
+
'<div class="micro-activity"></div>' +
|
|
1809
3596
|
'<div class="steps-mini"></div>' +
|
|
1810
3597
|
'</div>' +
|
|
1811
3598
|
'<div class="session-controls">' +
|
|
@@ -1813,17 +3600,36 @@ ${SHARED_STYLES}
|
|
|
1813
3600
|
'<button class="view-thread active" data-view="thread">Thread</button>' +
|
|
1814
3601
|
'<button class="view-raw" data-view="raw">Log</button>' +
|
|
1815
3602
|
'</div>' +
|
|
1816
|
-
'<span class="status-pill ' + session.status + runningClass + '">' + session.status + '</span>' +
|
|
3603
|
+
'<span class="status-pill ' + session.status + runningClass + '">' + statusLabel(session.status) + '</span>' +
|
|
1817
3604
|
'<button class="kill">Kill</button>' +
|
|
1818
3605
|
'</div>' +
|
|
1819
3606
|
'</div>' +
|
|
1820
3607
|
'<div class="session-body" data-view="thread">' +
|
|
3608
|
+
// Session threading — "Continued from" indicator for failover sessions
|
|
3609
|
+
(session.continuedFrom ? '<div class="session-continued-from">Continued from <a href="#' + escape(session.continuedFrom) + '">' + escape(agentLabel(session.agent)) + '</a> session</div>' : '') +
|
|
1821
3610
|
// Order: thread messages (chronological) → final report (closing
|
|
1822
3611
|
// summary, rendered as a block when artifacts arrive) → reply input.
|
|
1823
3612
|
// Apr 29 bug fix: previously final-report was BEFORE thread-messages
|
|
1824
3613
|
// so on completed sessions the Final Report appeared above the user's
|
|
1825
3614
|
// seed prompt, breaking chronological order in thread view.
|
|
1826
3615
|
'<div class="thread-messages"></div>' +
|
|
3616
|
+
// P18 slice 4 — permission-gate stack. Empty container; panels
|
|
3617
|
+
// get inserted by the per-session blocks poll when the per-tool
|
|
3618
|
+
// hook fires for this session. Each block becomes one panel with
|
|
3619
|
+
// an "Approve in this folder" button.
|
|
3620
|
+
'<div class="permission-gates"></div>' +
|
|
3621
|
+
// v0.6 M3 — PM narrator block. Hidden until first narration
|
|
3622
|
+
// arrives; renders one paragraph plain-English of what the
|
|
3623
|
+
// agent is doing right now. Per claim_user_persona HIGH:
|
|
3624
|
+
// non-technical users read this, not the tool calls.
|
|
3625
|
+
// Per feedback_overconfidence_guard.md: framed as "narrator"
|
|
3626
|
+
// not authoritative truth — narration is an LLM call, may
|
|
3627
|
+
// hallucinate.
|
|
3628
|
+
'<div class="narrator-block" style="display:none">' +
|
|
3629
|
+
'<div class="narrator-label">narrator</div>' +
|
|
3630
|
+
'<div class="narrator-text"></div>' +
|
|
3631
|
+
'<div class="narrator-meta"></div>' +
|
|
3632
|
+
'</div>' +
|
|
1827
3633
|
'<div class="final-report" style="display:none"></div>' +
|
|
1828
3634
|
// Iter 12 — reply surface now functional; replaced the prior dead
|
|
1829
3635
|
// placeholder. Active for adapters with canAppendMessage=true
|
|
@@ -1835,16 +3641,301 @@ ${SHARED_STYLES}
|
|
|
1835
3641
|
'<div class="reply-status" style="display:none"></div>' +
|
|
1836
3642
|
'</div>' +
|
|
1837
3643
|
'<div class="event-log"></div>' +
|
|
3644
|
+
'</div>' +
|
|
3645
|
+
// Pattern 3 — session footer. Hidden until populated (data-empty=true);
|
|
3646
|
+
// renderToolProgress flips it to data-empty=false once steps appear.
|
|
3647
|
+
'<div class="session-foot" data-empty="true">' +
|
|
3648
|
+
'<span class="sf-item sf-elapsed"><b>0s</b><span>elapsed</span></span>' +
|
|
3649
|
+
'<span class="sf-sep">·</span>' +
|
|
3650
|
+
'<span class="sf-item sf-tools"><b>0</b><span>tools</span></span>' +
|
|
3651
|
+
'<span class="sf-sep">·</span>' +
|
|
3652
|
+
'<span class="sf-item sf-files"><b>0</b><span>files</span></span>' +
|
|
1838
3653
|
'</div>';
|
|
1839
3654
|
|
|
1840
3655
|
sessionsEl.prepend(card);
|
|
1841
3656
|
emptyEl.style.display = 'none';
|
|
3657
|
+
// v0.6 S2 — first session attached means user has done step 1+2;
|
|
3658
|
+
// dismiss the onboarding card. They can re-open by clearing local
|
|
3659
|
+
// storage, but for the typical flow this is "I'm using it now".
|
|
3660
|
+
dismissOnboarding();
|
|
3661
|
+
|
|
3662
|
+
// Session threading — "continued as" indicator for sessions that already
|
|
3663
|
+
// have a failover successor (e.g., after page refresh).
|
|
3664
|
+
if (session.continuedAs) {
|
|
3665
|
+
var continuedAsEl = document.createElement('div');
|
|
3666
|
+
continuedAsEl.className = 'session-continued-as';
|
|
3667
|
+
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(session.continuedAs) + '">new session</a> · session ' + escape(session.continuedAs.slice(0, 8));
|
|
3668
|
+
var headEl2 = card.querySelector('.session-head');
|
|
3669
|
+
if (headEl2 && headEl2.parentNode) headEl2.parentNode.insertBefore(continuedAsEl, headEl2.nextSibling);
|
|
3670
|
+
}
|
|
1842
3671
|
|
|
1843
3672
|
const logEl = card.querySelector('.event-log');
|
|
1844
3673
|
const pillEl = card.querySelector('.status-pill');
|
|
1845
3674
|
const killBtn = card.querySelector('.kill');
|
|
1846
3675
|
const headEl = card.querySelector('.session-head');
|
|
1847
3676
|
const stepsEl = card.querySelector('.steps-mini');
|
|
3677
|
+
const microActivityEl = card.querySelector('.micro-activity');
|
|
3678
|
+
const toolProgressEl = card.querySelector('.tool-progress-chip');
|
|
3679
|
+
// Pattern 1 — tool-progress live chip. Renders "12 tools | 3/4 ●●●○ | Ns".
|
|
3680
|
+
// Sources from the same codehere:steps synthetic event renderSteps reads.
|
|
3681
|
+
function fmtElapsed(ms) {
|
|
3682
|
+
var s = Math.max(0, Math.floor(ms / 1000));
|
|
3683
|
+
if (s < 60) return s + 's';
|
|
3684
|
+
var m = Math.floor(s / 60);
|
|
3685
|
+
var rem = s % 60;
|
|
3686
|
+
if (m < 60) return rem ? m + 'm ' + rem + 's' : m + 'm';
|
|
3687
|
+
var h = Math.floor(m / 60);
|
|
3688
|
+
return h + 'h ' + (m % 60) + 'm';
|
|
3689
|
+
}
|
|
3690
|
+
function renderToolProgress(steps) {
|
|
3691
|
+
if (!toolProgressEl) return;
|
|
3692
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3693
|
+
toolProgressEl.dataset.state = 'hidden';
|
|
3694
|
+
toolProgressEl.innerHTML = '';
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
// Compute counts + state.
|
|
3698
|
+
var done = 0, active = 0, failed = 0, pending = 0;
|
|
3699
|
+
for (var i = 0; i < steps.length; i++) {
|
|
3700
|
+
var st = String(steps[i].state || '');
|
|
3701
|
+
if (st === 'done') done++;
|
|
3702
|
+
else if (st === 'active') active++;
|
|
3703
|
+
else if (st === 'failed') failed++;
|
|
3704
|
+
else pending++;
|
|
3705
|
+
}
|
|
3706
|
+
var total = steps.length;
|
|
3707
|
+
var chipState;
|
|
3708
|
+
if (failed > 0) chipState = 'failed';
|
|
3709
|
+
else if (session.status !== 'running') chipState = (done === total ? 'complete' : 'paused');
|
|
3710
|
+
else if (active > 0) chipState = 'running';
|
|
3711
|
+
else chipState = 'paused';
|
|
3712
|
+
|
|
3713
|
+
// Dot pattern — last 6 steps, oldest first. ● = done, ◐ = active,
|
|
3714
|
+
// ○ = pending, ✗ = failed. Cap at 6 so the chip doesn't grow.
|
|
3715
|
+
var tail = steps.slice(-6);
|
|
3716
|
+
var dots = tail.map(function(s) {
|
|
3717
|
+
var st = String(s.state || '');
|
|
3718
|
+
if (st === 'done') return '●';
|
|
3719
|
+
if (st === 'active') return '◐';
|
|
3720
|
+
if (st === 'failed') return '✗';
|
|
3721
|
+
return '○';
|
|
3722
|
+
}).join('');
|
|
3723
|
+
|
|
3724
|
+
var elapsed = fmtElapsed(Date.now() - session.startedAt);
|
|
3725
|
+
toolProgressEl.dataset.state = chipState;
|
|
3726
|
+
toolProgressEl.innerHTML =
|
|
3727
|
+
'<span>' + total + ' tool' + (total === 1 ? '' : 's') + '</span>' +
|
|
3728
|
+
'<span class="tp-sep">|</span>' +
|
|
3729
|
+
'<span>' + done + '/' + total + ' <span class="tp-dots">' + dots + '</span></span>' +
|
|
3730
|
+
'<span class="tp-sep">|</span>' +
|
|
3731
|
+
'<span class="tp-elapsed">' + elapsed + '</span>';
|
|
3732
|
+
|
|
3733
|
+
// Pattern 3 — keep the footer in sync with the same step data the
|
|
3734
|
+
// chip reads. Cheaper than a separate event subscription and stays
|
|
3735
|
+
// structurally consistent with the live counts above.
|
|
3736
|
+
renderSessionFoot(steps);
|
|
3737
|
+
}
|
|
3738
|
+
// Pattern 3 — session-foot: elapsed · tools · files. Reveals on first
|
|
3739
|
+
// step; updates counts on every renderSteps call. Read-only — no
|
|
3740
|
+
// click affordance (rec'y / file-list belong elsewhere).
|
|
3741
|
+
var sessionFootEl = card.querySelector('.session-foot');
|
|
3742
|
+
function renderSessionFoot(steps) {
|
|
3743
|
+
if (!sessionFootEl) return;
|
|
3744
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
3745
|
+
sessionFootEl.dataset.empty = 'true';
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
sessionFootEl.dataset.empty = 'false';
|
|
3749
|
+
var elapsed = session.completedAt
|
|
3750
|
+
? fmtElapsed(session.completedAt - session.startedAt)
|
|
3751
|
+
: fmtElapsed(Date.now() - session.startedAt);
|
|
3752
|
+
var elapsedB = sessionFootEl.querySelector('.sf-elapsed b');
|
|
3753
|
+
var toolsB = sessionFootEl.querySelector('.sf-tools b');
|
|
3754
|
+
var filesB = sessionFootEl.querySelector('.sf-files b');
|
|
3755
|
+
if (elapsedB) elapsedB.textContent = elapsed;
|
|
3756
|
+
if (toolsB) toolsB.textContent = String(steps.length);
|
|
3757
|
+
if (filesB) filesB.textContent = String(countTouchedFiles(steps));
|
|
3758
|
+
}
|
|
3759
|
+
// Tick elapsed display every second while running so the chip feels live.
|
|
3760
|
+
var elapsedTicker = null;
|
|
3761
|
+
function startElapsedTick() {
|
|
3762
|
+
if (elapsedTicker) return;
|
|
3763
|
+
elapsedTicker = setInterval(function() {
|
|
3764
|
+
if (!toolProgressEl || toolProgressEl.dataset.state === 'hidden') return;
|
|
3765
|
+
var elEl = toolProgressEl.querySelector('.tp-elapsed');
|
|
3766
|
+
if (elEl) elEl.textContent = fmtElapsed(Date.now() - session.startedAt);
|
|
3767
|
+
}, 1000);
|
|
3768
|
+
}
|
|
3769
|
+
function stopElapsedTick() {
|
|
3770
|
+
if (elapsedTicker) { clearInterval(elapsedTicker); elapsedTicker = null; }
|
|
3771
|
+
}
|
|
3772
|
+
if (session.status === 'running') startElapsedTick();
|
|
3773
|
+
|
|
3774
|
+
// v0.6 M3 — PM narrator polling. Backend generates a one-paragraph
|
|
3775
|
+
// mid-task summary every 30s while the session runs; frontend polls
|
|
3776
|
+
// /api/task/:id/narration and renders it in the narrator-block.
|
|
3777
|
+
// Per claim_user_persona: non-technical users read narration, not
|
|
3778
|
+
// tool calls. Per overconfidence-guard: framed as "narrator", not
|
|
3779
|
+
// authoritative truth.
|
|
3780
|
+
var narrTicker = null;
|
|
3781
|
+
var lastNarrAt = 0;
|
|
3782
|
+
function pollNarration() {
|
|
3783
|
+
fetch('/api/task/' + session.sessionId + '/narration')
|
|
3784
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3785
|
+
.then(function(d) {
|
|
3786
|
+
if (!d || !d.narration) return;
|
|
3787
|
+
var n = d.narration;
|
|
3788
|
+
if (n.generatedAt <= lastNarrAt) return; // unchanged
|
|
3789
|
+
lastNarrAt = n.generatedAt;
|
|
3790
|
+
var block = card.querySelector('.narrator-block');
|
|
3791
|
+
if (!block) return;
|
|
3792
|
+
var textEl = block.querySelector('.narrator-text');
|
|
3793
|
+
var metaEl = block.querySelector('.narrator-meta');
|
|
3794
|
+
if (textEl) textEl.textContent = n.summary;
|
|
3795
|
+
if (metaEl) {
|
|
3796
|
+
var ago = Math.max(0, Math.floor((Date.now() - n.generatedAt) / 1000));
|
|
3797
|
+
metaEl.textContent = 'after ' + n.stepCountAtSnapshot + ' steps · ' + ago + 's ago · may be inaccurate';
|
|
3798
|
+
}
|
|
3799
|
+
block.style.display = 'block';
|
|
3800
|
+
})
|
|
3801
|
+
.catch(function() { /* skip */ });
|
|
3802
|
+
}
|
|
3803
|
+
function startNarrationTick() {
|
|
3804
|
+
if (narrTicker) return;
|
|
3805
|
+
pollNarration(); // immediate
|
|
3806
|
+
narrTicker = setInterval(pollNarration, 30000);
|
|
3807
|
+
}
|
|
3808
|
+
function stopNarrationTick() {
|
|
3809
|
+
if (narrTicker) { clearInterval(narrTicker); narrTicker = null; }
|
|
3810
|
+
}
|
|
3811
|
+
if (session.status === 'running') startNarrationTick();
|
|
3812
|
+
|
|
3813
|
+
// P18 slice 4 — poll /api/task/:id/blocks for hook-blocks against
|
|
3814
|
+
// this session. Render one .permission-gate panel per unique block.
|
|
3815
|
+
// De-dup by hash of category + matched + timestamp.
|
|
3816
|
+
var blockTicker = null;
|
|
3817
|
+
var seenBlockKeys = Object.create(null);
|
|
3818
|
+
function escapeHtmlInline(s) {
|
|
3819
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
|
3820
|
+
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
function renderBlockGate(block) {
|
|
3824
|
+
// The block entry shape comes from the audit log; toolInput may be
|
|
3825
|
+
// a JSON string with {agent, command, allowedByPermissions, ...}
|
|
3826
|
+
var key = (block.timestamp || '') + '|' + (block.toolName || '') + '|' + (block.findings || 0);
|
|
3827
|
+
if (seenBlockKeys[key]) return;
|
|
3828
|
+
seenBlockKeys[key] = true;
|
|
3829
|
+
var gateContainer = card.querySelector('.permission-gates');
|
|
3830
|
+
if (!gateContainer) return;
|
|
3831
|
+
// Pull a useful matched-text + cwd out of the block. The audit
|
|
3832
|
+
// entry surfaces them in toolInput JSON shape.
|
|
3833
|
+
var pattern = '';
|
|
3834
|
+
var cwd = '';
|
|
3835
|
+
try {
|
|
3836
|
+
var parsedInput = typeof block.toolInput === 'string' ? JSON.parse(block.toolInput) : block.toolInput;
|
|
3837
|
+
if (parsedInput && typeof parsedInput === 'object') {
|
|
3838
|
+
// command (Bash) or file_path (Edit/Write) — best-effort
|
|
3839
|
+
pattern = String(parsedInput.command || parsedInput.file_path || '');
|
|
3840
|
+
cwd = String(parsedInput.cwd || '');
|
|
3841
|
+
}
|
|
3842
|
+
} catch (_) { /* not JSON */ }
|
|
3843
|
+
var truncatedPattern = pattern.length > 200 ? pattern.slice(0, 200) + '…' : pattern;
|
|
3844
|
+
var gate = document.createElement('div');
|
|
3845
|
+
gate.className = 'permission-gate';
|
|
3846
|
+
gate.innerHTML =
|
|
3847
|
+
'<div class="pg-label">codehere noticed a pattern</div>' +
|
|
3848
|
+
'<div class="pg-title">Is this expected here? You can approve it for this folder so future runs do not stop on the same pattern.</div>' +
|
|
3849
|
+
'<div class="pg-detail">' + escapeHtmlInline(truncatedPattern || '(no command captured)') + '</div>' +
|
|
3850
|
+
'<div class="pg-actions">' +
|
|
3851
|
+
'<button type="button" class="pg-approve">Approve in this folder</button>' +
|
|
3852
|
+
'<button type="button" class="pg-keep">Keep blocking</button>' +
|
|
3853
|
+
'</div>' +
|
|
3854
|
+
'<div class="pg-status">Approved · added to .codehere/permissions.yaml</div>';
|
|
3855
|
+
gateContainer.appendChild(gate);
|
|
3856
|
+
gate.querySelector('.pg-keep').addEventListener('click', function() {
|
|
3857
|
+
gate.remove();
|
|
3858
|
+
});
|
|
3859
|
+
gate.querySelector('.pg-approve').addEventListener('click', function() {
|
|
3860
|
+
var btn = gate.querySelector('.pg-approve');
|
|
3861
|
+
btn.disabled = true;
|
|
3862
|
+
btn.textContent = '…';
|
|
3863
|
+
// Approve the EXACT pattern that triggered the block; reason
|
|
3864
|
+
// captures the block's matched-finding context for the audit.
|
|
3865
|
+
// Fall back to a substring of the command if no finding text.
|
|
3866
|
+
var approvePattern = pattern;
|
|
3867
|
+
var reasonSuffix = block.findings ? (block.findings + ' finding' + (block.findings === 1 ? '' : 's') + ' caught at this site') : 'caught by hook';
|
|
3868
|
+
var reason = 'Approved via dashboard panel · ' + reasonSuffix;
|
|
3869
|
+
if (!cwd) {
|
|
3870
|
+
alert('Cannot approve: no cwd captured in the audit entry. Use codehere permission allow on the CLI.');
|
|
3871
|
+
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
fetch('/api/permissions', {
|
|
3875
|
+
method: 'POST',
|
|
3876
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3877
|
+
body: JSON.stringify({ cwd: cwd, pattern: approvePattern, reason: reason }),
|
|
3878
|
+
})
|
|
3879
|
+
.then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
|
|
3880
|
+
.then(function() {
|
|
3881
|
+
gate.classList.add('approved');
|
|
3882
|
+
})
|
|
3883
|
+
.catch(function() {
|
|
3884
|
+
btn.disabled = false; btn.textContent = 'Approve in this folder';
|
|
3885
|
+
alert('Approve failed — try the CLI: codehere permission allow ' + JSON.stringify(approvePattern));
|
|
3886
|
+
});
|
|
3887
|
+
});
|
|
3888
|
+
}
|
|
3889
|
+
function pollBlocks() {
|
|
3890
|
+
fetch('/api/task/' + session.sessionId + '/blocks')
|
|
3891
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
3892
|
+
.then(function(d) {
|
|
3893
|
+
if (!d || !Array.isArray(d.blocks)) return;
|
|
3894
|
+
for (var i = 0; i < d.blocks.length; i++) renderBlockGate(d.blocks[i]);
|
|
3895
|
+
})
|
|
3896
|
+
.catch(function() { /* skip */ });
|
|
3897
|
+
}
|
|
3898
|
+
function startBlockTick() {
|
|
3899
|
+
if (blockTicker) return;
|
|
3900
|
+
pollBlocks();
|
|
3901
|
+
blockTicker = setInterval(pollBlocks, 30000);
|
|
3902
|
+
}
|
|
3903
|
+
function stopBlockTick() {
|
|
3904
|
+
if (blockTicker) { clearInterval(blockTicker); blockTicker = null; }
|
|
3905
|
+
}
|
|
3906
|
+
if (session.status === 'running') startBlockTick();
|
|
3907
|
+
|
|
3908
|
+
// Pattern 13 — start whimsy rotator only while status === 'running' and
|
|
3909
|
+
// there's no active tool step. stopWhimsy() runs on exit (or on every
|
|
3910
|
+
// active-step swap; we restart it when the active step ends).
|
|
3911
|
+
var stopWhimsy = null;
|
|
3912
|
+
var lastMode = ''; // 'whimsy' | 'tool' | ''
|
|
3913
|
+
function setMicroState(mode, label) {
|
|
3914
|
+
if (!microActivityEl) return;
|
|
3915
|
+
if (mode === '') {
|
|
3916
|
+
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3917
|
+
microActivityEl.classList.remove('live', 'tool');
|
|
3918
|
+
microActivityEl.textContent = '';
|
|
3919
|
+
lastMode = '';
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
if (mode === 'tool') {
|
|
3923
|
+
if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
|
|
3924
|
+
microActivityEl.classList.add('live', 'tool');
|
|
3925
|
+
microActivityEl.textContent = label || 'Working…';
|
|
3926
|
+
lastMode = 'tool';
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
// mode === 'whimsy'
|
|
3930
|
+
microActivityEl.classList.add('live');
|
|
3931
|
+
microActivityEl.classList.remove('tool');
|
|
3932
|
+
if (lastMode !== 'whimsy') {
|
|
3933
|
+
if (stopWhimsy) stopWhimsy();
|
|
3934
|
+
stopWhimsy = startWhimsyRotator(microActivityEl);
|
|
3935
|
+
lastMode = 'whimsy';
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
if (session.status === 'running') setMicroState('whimsy');
|
|
1848
3939
|
const finalReportEl = card.querySelector('.final-report');
|
|
1849
3940
|
const threadEl = card.querySelector('.thread-messages');
|
|
1850
3941
|
const bodyEl = card.querySelector('.session-body');
|
|
@@ -1855,6 +3946,7 @@ ${SHARED_STYLES}
|
|
|
1855
3946
|
const replyStatusEl = card.querySelector('.reply-status');
|
|
1856
3947
|
if (!session.canAppendMessage) {
|
|
1857
3948
|
threadInputEl.classList.add('disabled');
|
|
3949
|
+
threadInputEl.title = 'Replies require a live session — re-spawn to continue this thread';
|
|
1858
3950
|
// All adapters wire appendMessage as of v0.6.0 Slices 1-3. This branch
|
|
1859
3951
|
// now only fires for sessions evicted from in-memory tracking (the
|
|
1860
3952
|
// canAppendMessage flag persists from spawn but the API endpoint
|
|
@@ -2077,8 +4169,12 @@ ${SHARED_STYLES}
|
|
|
2077
4169
|
// steps with pass/active/fail glyphs; hide the rest behind "+ N more" so
|
|
2078
4170
|
// the card stays compact even on a 20-step task.
|
|
2079
4171
|
function renderSteps(steps) {
|
|
4172
|
+
// Pattern 1 — update the tool-progress chip on every step snapshot.
|
|
4173
|
+
renderToolProgress(steps);
|
|
2080
4174
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
2081
4175
|
stepsEl.innerHTML = '';
|
|
4176
|
+
// Pattern 13: no steps + still running → keep whimsy rotation alive.
|
|
4177
|
+
if (session.status === 'running' && lastMode !== 'whimsy') setMicroState('whimsy');
|
|
2082
4178
|
return;
|
|
2083
4179
|
}
|
|
2084
4180
|
const tail = steps.slice(-3);
|
|
@@ -2088,9 +4184,67 @@ ${SHARED_STYLES}
|
|
|
2088
4184
|
tail.map(s => {
|
|
2089
4185
|
const st = String(s.state || 'pending');
|
|
2090
4186
|
const g = glyph[st] || '·';
|
|
2091
|
-
|
|
4187
|
+
// Pattern 2 — derive tool kind + split label so the verb-color
|
|
4188
|
+
// semantic CSS applies (read/grep muted, write/edit green, bash
|
|
4189
|
+
// peach). Falls back to a single .verb span when label is one word.
|
|
4190
|
+
// Pattern 14 — smart-truncate the target per tool kind so the
|
|
4191
|
+
// meaningful chunk survives the trace's narrow column.
|
|
4192
|
+
const kind = inferToolKind(s.label);
|
|
4193
|
+
const parts = splitStepLabel(String(s.label || ''));
|
|
4194
|
+
const truncatedTarget = truncateTarget(kind, parts.target);
|
|
4195
|
+
const labelHtml = truncatedTarget
|
|
4196
|
+
? '<span class="verb">' + escape(parts.verb) + '</span><span class="target">' + escape(truncatedTarget) + '</span>'
|
|
4197
|
+
: '<span class="verb">' + escape(parts.verb) + '</span>';
|
|
4198
|
+
// P22 / Track B #8 — bash output preview. Render last 4
|
|
4199
|
+
// lines of step.output under the step header. Only shows
|
|
4200
|
+
// for done/failed steps (active steps don't have output yet).
|
|
4201
|
+
// Note: split-on-newline uses fromCharCode(10) to dodge the
|
|
4202
|
+
// outer TS template literal eating the backslash-n.
|
|
4203
|
+
var outputHtml = '';
|
|
4204
|
+
if (s.output && (st === 'done' || st === 'failed')) {
|
|
4205
|
+
var lines = String(s.output).split(String.fromCharCode(10));
|
|
4206
|
+
var tailLines = lines.slice(-4);
|
|
4207
|
+
outputHtml = '<div class="step-output">' +
|
|
4208
|
+
tailLines.map(function(ln) { return escape(ln); }).join('<br>') +
|
|
4209
|
+
'</div>';
|
|
4210
|
+
}
|
|
4211
|
+
return '<div class="step ' + st + '" data-tool="' + escape(kind) + '"><span class="ico">' + g + '</span>' + labelHtml + outputHtml + '</div>';
|
|
2092
4212
|
}).join('') +
|
|
2093
4213
|
(more > 0 ? '<div class="more">+ ' + more + ' earlier</div>' : '');
|
|
4214
|
+
|
|
4215
|
+
// Pattern 13: tool-prefixed verb takes over while a step is active.
|
|
4216
|
+
// Most-recent active step's label becomes the micro-activity text;
|
|
4217
|
+
// when no step is active we fall back to whimsy rotation.
|
|
4218
|
+
// Pattern 17: 3-bucket long-op indicator — 5s timer → 15s bar → 30s
|
|
4219
|
+
// still-running + ESC hint.
|
|
4220
|
+
const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
|
|
4221
|
+
if (active && session.status === 'running') {
|
|
4222
|
+
let label = String(active.label || 'Working on it…');
|
|
4223
|
+
const startedAt = Number(active.startedAt || 0);
|
|
4224
|
+
if (startedAt > 0) {
|
|
4225
|
+
const elapsedMs = Date.now() - startedAt;
|
|
4226
|
+
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
4227
|
+
if (elapsedSec >= 5) {
|
|
4228
|
+
const timer = fmtElapsed(elapsedMs);
|
|
4229
|
+
// 15s threshold: add indeterminate progress bar
|
|
4230
|
+
if (elapsedSec >= 15) {
|
|
4231
|
+
const barFill = Math.floor((elapsedSec % 15) / 15 * 8);
|
|
4232
|
+
const barEmpty = 8 - barFill;
|
|
4233
|
+
const bar = '▮'.repeat(barFill) + '░'.repeat(barEmpty);
|
|
4234
|
+
label += ' · ' + timer + ' ' + bar;
|
|
4235
|
+
} else {
|
|
4236
|
+
// 5–15s: elapsed timer only
|
|
4237
|
+
label += ' · ' + timer;
|
|
4238
|
+
}
|
|
4239
|
+
if (elapsedSec >= 30) label += ' · still running · ESC to cancel';
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
setMicroState('tool', label);
|
|
4243
|
+
} else if (session.status === 'running') {
|
|
4244
|
+
setMicroState('whimsy');
|
|
4245
|
+
} else {
|
|
4246
|
+
setMicroState('');
|
|
4247
|
+
}
|
|
2094
4248
|
}
|
|
2095
4249
|
|
|
2096
4250
|
// Layer 3: render the final report block when the task completes.
|
|
@@ -2104,6 +4258,7 @@ ${SHARED_STYLES}
|
|
|
2104
4258
|
// completed task is still generating its report (placeholder state).
|
|
2105
4259
|
const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
|
|
2106
4260
|
const hasContent = artifacts || finalReport;
|
|
4261
|
+
const reportFailed = session.failedReport === true;
|
|
2107
4262
|
// v0.6.x — bound the "Generating report…" placeholder to the actual
|
|
2108
4263
|
// generation window. Final Report typically returns within 30s; after
|
|
2109
4264
|
// 90s post-completion with no report, the report either failed silently
|
|
@@ -2111,10 +4266,10 @@ ${SHARED_STYLES}
|
|
|
2111
4266
|
// wiped). Either way, "Generating report…" forever is dishonest.
|
|
2112
4267
|
const REPORT_WAIT_BUDGET_MS = 90_000;
|
|
2113
4268
|
const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
|
|
2114
|
-
const stillWaitingForReport = isCompleted && !finalReport && sinceCompleted < REPORT_WAIT_BUDGET_MS;
|
|
2115
|
-
const reportTimedOut = isCompleted && !finalReport && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
|
|
4269
|
+
const stillWaitingForReport = isCompleted && !finalReport && !reportFailed && sinceCompleted < REPORT_WAIT_BUDGET_MS;
|
|
4270
|
+
const reportTimedOut = isCompleted && !finalReport && !reportFailed && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
|
|
2116
4271
|
const isAwaitingReport = stillWaitingForReport;
|
|
2117
|
-
if (!hasContent && !isAwaitingReport && !reportTimedOut) { finalReportEl.style.display = 'none'; return; }
|
|
4272
|
+
if (!hasContent && !isAwaitingReport && !reportTimedOut && !reportFailed) { finalReportEl.style.display = 'none'; return; }
|
|
2118
4273
|
finalReportEl.style.display = 'block';
|
|
2119
4274
|
const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
|
|
2120
4275
|
const tests = artifacts?.tests;
|
|
@@ -2130,6 +4285,8 @@ ${SHARED_STYLES}
|
|
|
2130
4285
|
'</div>';
|
|
2131
4286
|
} else if (isAwaitingReport) {
|
|
2132
4287
|
html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
|
|
4288
|
+
} else if (reportFailed) {
|
|
4289
|
+
html += '<div class="fr-summary fr-summary-pending"><p>Final Report generation failed. The agent finished but the summary couldn\\'t be created. Re-spawn to get a fresh report.</p></div>';
|
|
2133
4290
|
} else if (reportTimedOut) {
|
|
2134
4291
|
// Honest message instead of perpetual "Generating report…". Most
|
|
2135
4292
|
// common cause: daemon restarted (Final Report is in-memory only,
|
|
@@ -2138,16 +4295,29 @@ ${SHARED_STYLES}
|
|
|
2138
4295
|
html += '<div class="fr-summary fr-summary-pending"><p>Final Report unavailable for this session. (Most often this means the daemon restarted — reports aren\\'t persisted yet. Re-spawn to get a fresh one.)</p></div>';
|
|
2139
4296
|
}
|
|
2140
4297
|
if (files.length > 0) {
|
|
4298
|
+
// Pattern 7 — file chip with mutation tint, trust grade letter,
|
|
4299
|
+
// diff stats. Mutation kind derives from added/removed counts so
|
|
4300
|
+
// the file-icon color signals "create / edit / delete" at glance.
|
|
4301
|
+
const FILE_ICON_SVG = '<svg class="file-icon" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M3 1 L9 1 L11.5 3.5 L11.5 13 L3 13 Z"/><path d="M9 1 L9 3.5 L11.5 3.5"/></svg>';
|
|
4302
|
+
const trustGrade = (n) => n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F';
|
|
2141
4303
|
html += '<div class="fr-files">' + files.map(f => {
|
|
2142
|
-
const
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
4304
|
+
const added = Number(f.added || 0);
|
|
4305
|
+
const removed = Number(f.removed || 0);
|
|
4306
|
+
let mutation;
|
|
4307
|
+
if (added > 0 && removed === 0) mutation = 'write';
|
|
4308
|
+
else if (added === 0 && removed > 0) mutation = 'delete';
|
|
4309
|
+
else mutation = 'edit';
|
|
4310
|
+
const lines = '<span class="add">+' + added + '</span> <span class="rem">−' + removed + '</span>';
|
|
4311
|
+
// Pattern 11 — file chip uses the standalone trust-badge helper
|
|
4312
|
+
// instead of inline class-picking. Single source of truth for
|
|
4313
|
+
// grade-cutoff + color-split. (Note: this CORRECTS the prior
|
|
4314
|
+
// 60-cutoff which put D-grade trust in red; spec puts D in
|
|
4315
|
+
// warn-yellow.)
|
|
4316
|
+
return '<div class="fr-file" data-mutation="' + escape(mutation) + '">' +
|
|
4317
|
+
FILE_ICON_SVG +
|
|
2148
4318
|
'<span class="path" title="' + escape(String(f.path)) + '">' + escape(String(f.path)) + '</span>' +
|
|
2149
4319
|
'<span class="lines">' + lines + '</span>' +
|
|
2150
|
-
|
|
4320
|
+
trustBadgeHTML(typeof f.trust === 'number' ? f.trust : null) +
|
|
2151
4321
|
'</div>';
|
|
2152
4322
|
}).join('') + '</div>';
|
|
2153
4323
|
} else {
|
|
@@ -2179,7 +4349,10 @@ ${SHARED_STYLES}
|
|
|
2179
4349
|
// Persisted sessions have no live event stream and no subprocess to kill.
|
|
2180
4350
|
if (isPersisted) {
|
|
2181
4351
|
killBtn.style.display = 'none';
|
|
2182
|
-
|
|
4352
|
+
// Use .persisted (visible, faded). Pre-fix this added .observed
|
|
4353
|
+
// which then collided with the detected-process hide rule from
|
|
4354
|
+
// claim_user_persona work — your own past sessions disappeared.
|
|
4355
|
+
card.classList.add('persisted');
|
|
2183
4356
|
return;
|
|
2184
4357
|
}
|
|
2185
4358
|
|
|
@@ -2192,11 +4365,67 @@ ${SHARED_STYLES}
|
|
|
2192
4365
|
if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
|
|
2193
4366
|
if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
|
|
2194
4367
|
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
4368
|
+
// Pattern 6 · codehere:error events carry a structured code that maps
|
|
4369
|
+
// 1:1 to a macro state. rate_limit / quota_exceeded / credit_low /
|
|
4370
|
+
// auth_invalid / model_unavailable each get their own pill copy +
|
|
4371
|
+
// color so users can see WHY the agent stopped, not just THAT it did.
|
|
4372
|
+
// Falls back to 'error' (red "Failed") for unknown codes.
|
|
2198
4373
|
if (event.type === 'codehere:error') {
|
|
2199
|
-
|
|
4374
|
+
var code = event.data && event.data.code;
|
|
4375
|
+
var state = code && STATE_LABELS[code] ? code : 'error';
|
|
4376
|
+
updateStatus(pillEl, state);
|
|
4377
|
+
// Pattern 9 — surface a recovery affordance below the session
|
|
4378
|
+
// header for known failure codes. Idempotent: if already shown,
|
|
4379
|
+
// skip (sessions can emit codehere:error multiple times).
|
|
4380
|
+
if (code && !card.querySelector('.recovery-affordance')) {
|
|
4381
|
+
var nextAgent = pickFailoverTarget(session.agent);
|
|
4382
|
+
var built = buildRecoveryAffordance(code, session.agent, nextAgent);
|
|
4383
|
+
// Insert between session-head and session-body so the affordance
|
|
4384
|
+
// sits at the top of the card body (most-prominent position).
|
|
4385
|
+
var bodyEl = card.querySelector('.session-body');
|
|
4386
|
+
if (bodyEl) bodyEl.parentNode.insertBefore(built.el, bodyEl);
|
|
4387
|
+
else card.appendChild(built.el);
|
|
4388
|
+
built.el.querySelector('.ra-secondary').addEventListener('click', function() {
|
|
4389
|
+
built.el.remove();
|
|
4390
|
+
});
|
|
4391
|
+
built.el.querySelector('.ra-primary').addEventListener('click', async function() {
|
|
4392
|
+
if (!built.copy.canFailover) {
|
|
4393
|
+
built.el.remove();
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
var primary = built.el.querySelector('.ra-primary');
|
|
4397
|
+
primary.disabled = true;
|
|
4398
|
+
primary.textContent = 'Switching…';
|
|
4399
|
+
try {
|
|
4400
|
+
var r = await fetch('/api/failover-accept', {
|
|
4401
|
+
method: 'POST',
|
|
4402
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4403
|
+
body: JSON.stringify({ sessionId: session.sessionId, targetAgent: nextAgent })
|
|
4404
|
+
});
|
|
4405
|
+
var d = await r.json();
|
|
4406
|
+
if (d.error || (!d.ok || !d.newSessionId)) {
|
|
4407
|
+
built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> ' + escape(d.detail || 'Target agent not ready');
|
|
4408
|
+
primary.style.display = 'none';
|
|
4409
|
+
built.el.querySelector('.ra-secondary').textContent = 'Dismiss';
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
// Session threading: add "continued as" link on the original card
|
|
4413
|
+
var continuedAsEl = document.createElement('div');
|
|
4414
|
+
continuedAsEl.className = 'session-continued-as';
|
|
4415
|
+
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(nextAgent) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
|
|
4416
|
+
// Insert after session-head
|
|
4417
|
+
var headEl = card.querySelector('.session-head');
|
|
4418
|
+
if (headEl && headEl.parentNode) headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
|
|
4419
|
+
built.el.querySelector('.ra-msg').innerHTML = '↻ Continued as <b>' + escape(nextAgent) + '</b>';
|
|
4420
|
+
primary.style.display = 'none';
|
|
4421
|
+
built.el.querySelector('.ra-secondary').textContent = 'OK';
|
|
4422
|
+
} catch {
|
|
4423
|
+
built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> Network error';
|
|
4424
|
+
primary.disabled = false;
|
|
4425
|
+
primary.textContent = 'Retry';
|
|
4426
|
+
}
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
2200
4429
|
// fall through — renderEvent renders the red banner in the log
|
|
2201
4430
|
}
|
|
2202
4431
|
|
|
@@ -2210,22 +4439,75 @@ ${SHARED_STYLES}
|
|
|
2210
4439
|
killBtn.disabled = true; killBtn.style.opacity = '0.4';
|
|
2211
4440
|
const dot = card.querySelector('.sub .live-dot');
|
|
2212
4441
|
if (dot) dot.remove();
|
|
4442
|
+
setMicroState(''); // Pattern 13 — stop rotator on session exit
|
|
4443
|
+
stopElapsedTick(); // Pattern 1 — stop the chip's elapsed ticker
|
|
4444
|
+
stopNarrationTick(); // v0.6 M3 — stop the narrator poll on exit
|
|
4445
|
+
// Final narration fetch — backend may emit one last summary
|
|
4446
|
+
// after the exit event; give it a beat then poll once more.
|
|
4447
|
+
setTimeout(pollNarration, 2000);
|
|
4448
|
+
stopBlockTick(); // P18 slice 4 — stop the per-session block poll
|
|
4449
|
+
// Final blocks fetch — agent may have run one last hook-blocked
|
|
4450
|
+
// tool call right before exit; surface it.
|
|
4451
|
+
setTimeout(pollBlocks, 2000);
|
|
4452
|
+
// Final chip render with the terminal status so it shows the last
|
|
4453
|
+
// dot pattern + final elapsed value in the chip's complete/failed state.
|
|
4454
|
+
if (Array.isArray(session.steps)) renderToolProgress(session.steps);
|
|
2213
4455
|
es.close();
|
|
2214
4456
|
}
|
|
2215
4457
|
if (event.type === 'error') {
|
|
4458
|
+
setMicroState(''); // Pattern 13
|
|
2216
4459
|
updateStatus(pillEl, 'error');
|
|
2217
4460
|
}
|
|
2218
4461
|
} catch { /* skip */ }
|
|
2219
4462
|
};
|
|
2220
4463
|
es.onerror = () => { /* server may have closed after exit; fine */ };
|
|
2221
4464
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
4465
|
+
// Pattern 19 — interrupt affordance with 2-press confirm + ESC shortcut.
|
|
4466
|
+
// First press flips the button to a "press again to confirm" red state
|
|
4467
|
+
// for 3s; second press during that window actually kills. ESC while the
|
|
4468
|
+
// card is focused triggers the same flow. Prevents accidental kills.
|
|
4469
|
+
var killConfirmTimer = null;
|
|
4470
|
+
function armKillConfirm() {
|
|
4471
|
+
killBtn.dataset.state = 'confirming';
|
|
4472
|
+
killBtn.textContent = 'Confirm?';
|
|
4473
|
+
if (killConfirmTimer) clearTimeout(killConfirmTimer);
|
|
4474
|
+
killConfirmTimer = setTimeout(function() {
|
|
4475
|
+
killBtn.dataset.state = 'ready';
|
|
4476
|
+
killBtn.textContent = 'Kill';
|
|
4477
|
+
killConfirmTimer = null;
|
|
4478
|
+
}, 3000);
|
|
4479
|
+
}
|
|
4480
|
+
async function performKill() {
|
|
4481
|
+
if (killConfirmTimer) { clearTimeout(killConfirmTimer); killConfirmTimer = null; }
|
|
4482
|
+
killBtn.disabled = true;
|
|
4483
|
+
killBtn.dataset.state = 'killing';
|
|
4484
|
+
killBtn.textContent = '…';
|
|
2225
4485
|
try {
|
|
2226
4486
|
await fetch('/api/task/' + session.sessionId + '/kill', { method: 'POST' });
|
|
2227
4487
|
} catch { /* ignore */ }
|
|
4488
|
+
killBtn.dataset.state = 'ready';
|
|
2228
4489
|
killBtn.textContent = 'Kill';
|
|
4490
|
+
killBtn.disabled = false;
|
|
4491
|
+
}
|
|
4492
|
+
killBtn.dataset.state = 'ready';
|
|
4493
|
+
killBtn.addEventListener('click', function(ev) {
|
|
4494
|
+
ev.stopPropagation();
|
|
4495
|
+
if (killBtn.dataset.state === 'confirming') {
|
|
4496
|
+
performKill();
|
|
4497
|
+
} else {
|
|
4498
|
+
armKillConfirm();
|
|
4499
|
+
}
|
|
4500
|
+
});
|
|
4501
|
+
// ESC keyboard shortcut — fires when the session card has focus within.
|
|
4502
|
+
card.addEventListener('keydown', function(ev) {
|
|
4503
|
+
if (ev.key !== 'Escape') return;
|
|
4504
|
+
if (killBtn.disabled) return;
|
|
4505
|
+
ev.preventDefault();
|
|
4506
|
+
if (killBtn.dataset.state === 'confirming') {
|
|
4507
|
+
performKill();
|
|
4508
|
+
} else {
|
|
4509
|
+
armKillConfirm();
|
|
4510
|
+
}
|
|
2229
4511
|
});
|
|
2230
4512
|
|
|
2231
4513
|
// Iter 12 — expose renderArtifacts so the polling loop can refresh the
|
|
@@ -2239,8 +4521,14 @@ ${SHARED_STYLES}
|
|
|
2239
4521
|
if (!prompt) return;
|
|
2240
4522
|
// Apr 29: empty value = "Auto" option = backend defaults to conductor
|
|
2241
4523
|
// (the supervisor routes). Specialists are explicit overrides.
|
|
4524
|
+
// M5 slice 2: agent === '*' = fan-out (every installed specialist in
|
|
4525
|
+
// parallel via /api/task/batch).
|
|
2242
4526
|
const agent = agentSelect.value;
|
|
2243
4527
|
const cwd = cwdInput.value.trim();
|
|
4528
|
+
if (agent === '*') {
|
|
4529
|
+
await assignParallel(prompt, cwd);
|
|
4530
|
+
return;
|
|
4531
|
+
}
|
|
2244
4532
|
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
2245
4533
|
try {
|
|
2246
4534
|
const res = await fetch('/api/task', {
|
|
@@ -2249,9 +4537,77 @@ ${SHARED_STYLES}
|
|
|
2249
4537
|
body: JSON.stringify({ prompt, agent: agent || undefined, cwd: cwd || undefined }),
|
|
2250
4538
|
});
|
|
2251
4539
|
const data = await res.json();
|
|
2252
|
-
if (!res.ok) {
|
|
4540
|
+
if (!res.ok) {
|
|
4541
|
+
// v0.6 S3 slice 2 — friendly message on daily-budget refusal so
|
|
4542
|
+
// non-technical users see the WHY + the next move instead of a
|
|
4543
|
+
// raw error code.
|
|
4544
|
+
if (res.status === 429 && data.error === 'daily_budget_exhausted') {
|
|
4545
|
+
alert(data.message || 'Daily budget reached.');
|
|
4546
|
+
} else {
|
|
4547
|
+
alert('Failed: ' + (data.error || res.status));
|
|
4548
|
+
}
|
|
4549
|
+
return;
|
|
4550
|
+
}
|
|
2253
4551
|
attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
|
|
2254
4552
|
promptEl.value = '';
|
|
4553
|
+
// Spend chip refreshes on its own 60s tick; nudge it now since a
|
|
4554
|
+
// spawn means new spend is imminent.
|
|
4555
|
+
refreshSpendToday();
|
|
4556
|
+
} catch (e) {
|
|
4557
|
+
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
4558
|
+
} finally {
|
|
4559
|
+
spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
// M5 slice 2 — fan-out spawn. Sends the same prompt to every installed
|
|
4564
|
+
// ready specialist in parallel via /api/task/batch. Cap of 5 enforced
|
|
4565
|
+
// by the backend; if the user has more than 5 ready specialists we
|
|
4566
|
+
// take the first 5 in the readiness order (sort already favors ready).
|
|
4567
|
+
async function assignParallel(prompt, cwd) {
|
|
4568
|
+
const readySpecialists = (agentReadiness || [])
|
|
4569
|
+
.filter(a => a.state === 'ready')
|
|
4570
|
+
.map(a => a.id)
|
|
4571
|
+
.slice(0, 5);
|
|
4572
|
+
if (readySpecialists.length < 2) {
|
|
4573
|
+
alert('Fan-out needs at least 2 ready agents. Currently ready: ' + readySpecialists.length);
|
|
4574
|
+
return;
|
|
4575
|
+
}
|
|
4576
|
+
spawnBtn.disabled = true; spawnBtn.textContent = '...';
|
|
4577
|
+
try {
|
|
4578
|
+
const res = await fetch('/api/task/batch', {
|
|
4579
|
+
method: 'POST',
|
|
4580
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4581
|
+
body: JSON.stringify({
|
|
4582
|
+
tasks: readySpecialists.map(agentId => ({
|
|
4583
|
+
prompt,
|
|
4584
|
+
agent: agentId,
|
|
4585
|
+
cwd: cwd || undefined,
|
|
4586
|
+
})),
|
|
4587
|
+
}),
|
|
4588
|
+
});
|
|
4589
|
+
const data = await res.json();
|
|
4590
|
+
if (!res.ok) { alert('Batch failed: ' + (data.error || res.status)); return; }
|
|
4591
|
+
// Attach a card per spawned task. Per-task errors get surfaced
|
|
4592
|
+
// via alert listing the failed agents — successful spawns still
|
|
4593
|
+
// attach.
|
|
4594
|
+
const failed = [];
|
|
4595
|
+
for (const t of (data.tasks || [])) {
|
|
4596
|
+
if (t.sessionId) {
|
|
4597
|
+
attachSession({
|
|
4598
|
+
sessionId: t.sessionId,
|
|
4599
|
+
prompt,
|
|
4600
|
+
startedAt: t.startedAt,
|
|
4601
|
+
status: t.status,
|
|
4602
|
+
agent: t.agent,
|
|
4603
|
+
canAppendMessage: false, // batch doesn't surface this; default conservative
|
|
4604
|
+
});
|
|
4605
|
+
} else if (t.error) {
|
|
4606
|
+
failed.push(t.agent + ': ' + (t.reason || t.error));
|
|
4607
|
+
}
|
|
4608
|
+
}
|
|
4609
|
+
if (failed.length > 0) alert(failed.length + ' of ' + (data.tasks || []).length + ' fan-out spawns failed: ' + failed.join('; '));
|
|
4610
|
+
promptEl.value = '';
|
|
2255
4611
|
} catch (e) {
|
|
2256
4612
|
alert('Network error: ' + (e && e.message ? e.message : e));
|
|
2257
4613
|
} finally {
|
|
@@ -2375,8 +4731,7 @@ ${SHARED_STYLES}
|
|
|
2375
4731
|
'<div class="session-head">' +
|
|
2376
4732
|
'<div class="meta">' +
|
|
2377
4733
|
'<div class="prompt">' +
|
|
2378
|
-
'
|
|
2379
|
-
escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
|
|
4734
|
+
agentBadgeHTML(a.id || a.label) + ' ' + escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
|
|
2380
4735
|
'</div>' +
|
|
2381
4736
|
'<div class="sub">running ' + escape(uptime) + '</div>' +
|
|
2382
4737
|
'</div>' +
|
|
@@ -2406,9 +4761,10 @@ ${SHARED_STYLES}
|
|
|
2406
4761
|
if (!hasAny) { auditRow.classList.add('empty'); return; }
|
|
2407
4762
|
auditRow.classList.remove('empty');
|
|
2408
4763
|
document.getElementById('as-actions').textContent = String(d.actions || 0);
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
4764
|
+
// UX-simplification iter 2 (May 7): dropped writes to hidden as-blocked /
|
|
4765
|
+
// as-trust / as-agents spans — they were updated but never displayed.
|
|
4766
|
+
// The mode-indicator pill in the nav now carries the safety-posture
|
|
4767
|
+
// signal those hidden values were once meant to surface.
|
|
2412
4768
|
}
|
|
2413
4769
|
function refreshAudit() {
|
|
2414
4770
|
fetch('/api/audit/summary').then(r => r.json()).then(updateAudit).catch(() => { /* fine */ });
|
|
@@ -2416,39 +4772,18 @@ ${SHARED_STYLES}
|
|
|
2416
4772
|
refreshAudit();
|
|
2417
4773
|
setInterval(refreshAudit, 30000); // audit log isn't hot; 30s is plenty
|
|
2418
4774
|
|
|
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
|
-
}
|
|
4775
|
+
// Apr 29 status-banner is now the single stats-line above ("X actions
|
|
4776
|
+
// today"). UX-simplification iter 2 (May 7) dropped the hidden setText
|
|
4777
|
+
// counters + the per-second timer that updated sb-last for a value
|
|
4778
|
+
// that was never displayed. updateBanner still toggles row visibility
|
|
4779
|
+
// when there's any session activity.
|
|
2432
4780
|
function updateBanner(d) {
|
|
2433
4781
|
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
4782
|
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) {
|
|
4783
|
+
if (spawned.length + detected > 0) {
|
|
2445
4784
|
auditRow.classList.remove('empty');
|
|
2446
4785
|
}
|
|
2447
4786
|
}
|
|
2448
|
-
setInterval(() => {
|
|
2449
|
-
const el = document.getElementById('sb-last');
|
|
2450
|
-
if (el && latestLastEditAt) el.textContent = agoStr(latestLastEditAt);
|
|
2451
|
-
}, 1000);
|
|
2452
4787
|
|
|
2453
4788
|
function loadSessions() {
|
|
2454
4789
|
fetch('/api/tasks').then(r => r.json()).then(d => {
|
|
@@ -2462,8 +4797,47 @@ ${SHARED_STYLES}
|
|
|
2462
4797
|
}
|
|
2463
4798
|
pruneStaleAgents(seen);
|
|
2464
4799
|
updateBanner(d);
|
|
4800
|
+
updateShowMoreButton();
|
|
4801
|
+
updateStopAllButton();
|
|
2465
4802
|
}).catch(() => { /* fine */ });
|
|
2466
4803
|
}
|
|
4804
|
+
// claim_user_persona — show "Show all sessions (N more)" button only
|
|
4805
|
+
// when there are more than 5 spawned sessions. Detected-process rows
|
|
4806
|
+
// don't count (they're already hidden by default).
|
|
4807
|
+
function updateShowMoreButton() {
|
|
4808
|
+
const btn = document.getElementById('show-more-sessions');
|
|
4809
|
+
if (!btn) return;
|
|
4810
|
+
const allSessions = sessionsEl.querySelectorAll('.session:not(.observed)');
|
|
4811
|
+
if (allSessions.length > 5 && !document.body.classList.contains('show-all-sessions')) {
|
|
4812
|
+
btn.style.display = 'block';
|
|
4813
|
+
btn.textContent = 'Show all sessions (' + (allSessions.length - 5) + ' more)';
|
|
4814
|
+
} else {
|
|
4815
|
+
btn.style.display = 'none';
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
const showMoreBtn = document.getElementById('show-more-sessions');
|
|
4819
|
+
if (showMoreBtn) showMoreBtn.addEventListener('click', function() {
|
|
4820
|
+
document.body.classList.add('show-all-sessions');
|
|
4821
|
+
updateShowMoreButton();
|
|
4822
|
+
});
|
|
4823
|
+
|
|
4824
|
+
// Stop all running sessions — one click kills every active session.
|
|
4825
|
+
const stopAllBtn = document.getElementById('stop-all-sessions');
|
|
4826
|
+
function updateStopAllButton() {
|
|
4827
|
+
if (!stopAllBtn) return;
|
|
4828
|
+
const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
|
|
4829
|
+
stopAllBtn.style.display = runningCards.length > 1 ? 'block' : 'none';
|
|
4830
|
+
stopAllBtn.textContent = 'Stop all running (' + runningCards.length + ')';
|
|
4831
|
+
}
|
|
4832
|
+
if (stopAllBtn) stopAllBtn.addEventListener('click', function() {
|
|
4833
|
+
if (!confirm('Stop all running sessions?')) return;
|
|
4834
|
+
const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
|
|
4835
|
+
runningCards.forEach(function(card) {
|
|
4836
|
+
const killBtn = card.querySelector('.session-controls button.kill');
|
|
4837
|
+
if (killBtn && !killBtn.disabled) killBtn.click();
|
|
4838
|
+
});
|
|
4839
|
+
updateStopAllButton();
|
|
4840
|
+
});
|
|
2467
4841
|
loadSessions();
|
|
2468
4842
|
// Refresh detected-agents list + banner every 5s. Iter 12 — also patches
|
|
2469
4843
|
// existing session cards with new finalReport prose when it arrives async.
|
|
@@ -2492,7 +4866,17 @@ ${SHARED_STYLES}
|
|
|
2492
4866
|
const readinessEl = document.getElementById('readiness');
|
|
2493
4867
|
|
|
2494
4868
|
function renderReadiness(report) {
|
|
2495
|
-
|
|
4869
|
+
// Hide the banner unless the state is genuinely actionable. May 5 2026
|
|
4870
|
+
// design pass: the prior api_key_shadow state surfaced a yellow warning
|
|
4871
|
+
// banner with shell-grep jargon for an environment condition that ISNT
|
|
4872
|
+
// a problem -- codehere already uses subscription auth and ignores the
|
|
4873
|
+
// ANTHROPIC_API_KEY env var by design. Per claim_user_persona (HIGH
|
|
4874
|
+
// non-technical-first) + the Open-Agents production-grade design
|
|
4875
|
+
// reference, only show the banner when the user can / must act on it
|
|
4876
|
+
// (needs_install, needs_auth, billed_key_required). Other states stay
|
|
4877
|
+
// observable in /api/readiness for tooling.
|
|
4878
|
+
const ACTIONABLE_STATES = new Set(['needs_install', 'needs_auth', 'billed_key_required']);
|
|
4879
|
+
if (!report || !ACTIONABLE_STATES.has(report.state)) {
|
|
2496
4880
|
readinessEl.style.display = 'none';
|
|
2497
4881
|
return;
|
|
2498
4882
|
}
|
|
@@ -2523,6 +4907,88 @@ ${SHARED_STYLES}
|
|
|
2523
4907
|
}
|
|
2524
4908
|
|
|
2525
4909
|
checkReadiness();
|
|
4910
|
+
|
|
4911
|
+
// ── Failover offer banner (P0) ──────────────────────────────────
|
|
4912
|
+
// Polls /api/failover-offers every 5s. When offers exist, shows a
|
|
4913
|
+
// banner: "Claude rate-limited — switch to Codex?" with one-click
|
|
4914
|
+
// accept (re-spawns on the target agent) and dismiss buttons.
|
|
4915
|
+
const fbEl = document.getElementById('failover-banner');
|
|
4916
|
+
let fbDismissed = false;
|
|
4917
|
+
|
|
4918
|
+
function renderFailoverOffers(offers) {
|
|
4919
|
+
if (!offers || offers.length === 0 || fbDismissed) {
|
|
4920
|
+
fbEl.style.display = 'none';
|
|
4921
|
+
return;
|
|
4922
|
+
}
|
|
4923
|
+
fbEl.style.display = 'flex';
|
|
4924
|
+
const parts = offers.map(function(o) {
|
|
4925
|
+
var msg = '<b>' + escape(o.signal.fromAgent) + '</b> hit ' + escape(o.signal.errorCode) +
|
|
4926
|
+
' — switch to <b>' + escape(o.target) + '</b>?';
|
|
4927
|
+
var actions = '<div class="fb-actions">' +
|
|
4928
|
+
'<button class="fb-accept" data-session="' + escape(o.sessionId) + '" data-target="' + escape(o.target) + '">Switch</button>' +
|
|
4929
|
+
'<button class="fb-dismiss" data-session="' + escape(o.sessionId) + '">Dismiss</button>' +
|
|
4930
|
+
'</div>';
|
|
4931
|
+
return '<div class="fb-msg">' + msg + '</div>' + actions;
|
|
4932
|
+
});
|
|
4933
|
+
fbEl.innerHTML = parts.join('');
|
|
4934
|
+
|
|
4935
|
+
// Wire accept buttons
|
|
4936
|
+
fbEl.querySelectorAll('.fb-accept').forEach(function(btn) {
|
|
4937
|
+
btn.addEventListener('click', function() {
|
|
4938
|
+
var sessionId = btn.dataset.session;
|
|
4939
|
+
var target = btn.dataset.target;
|
|
4940
|
+
btn.disabled = true;
|
|
4941
|
+
btn.textContent = 'Switching…';
|
|
4942
|
+
fetch('/api/failover-accept', {
|
|
4943
|
+
method: 'POST',
|
|
4944
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4945
|
+
body: JSON.stringify({ sessionId: sessionId, targetAgent: target }),
|
|
4946
|
+
}).then(function(r) { return r.json(); }).then(function(d) {
|
|
4947
|
+
if (d.error) {
|
|
4948
|
+
fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> ' + escape(d.error) + (d.detail ? ' — ' + escape(d.detail) : '') + '</div>';
|
|
4949
|
+
return;
|
|
4950
|
+
}
|
|
4951
|
+
if (!d.ok || !d.newSessionId) {
|
|
4952
|
+
fbEl.innerHTML = '<div class="fb-msg"><b>Failover unavailable:</b> ' + escape(d.detail || 'Target agent not ready') + '</div>';
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
// Session threading: add "continued as" link on the original card
|
|
4956
|
+
var origCard = document.querySelector('[data-session-id="' + escape(sessionId) + '"]');
|
|
4957
|
+
if (origCard && !origCard.querySelector('.session-continued-as')) {
|
|
4958
|
+
var headEl = origCard.querySelector('.session-head');
|
|
4959
|
+
if (headEl && headEl.parentNode) {
|
|
4960
|
+
var continuedAsEl = document.createElement('div');
|
|
4961
|
+
continuedAsEl.className = 'session-continued-as';
|
|
4962
|
+
continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(target) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
|
|
4963
|
+
headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
fbEl.innerHTML = '<div class="fb-msg">↻ Switched to <b>' + escape(target) + '</b>. New session: <b>' + escape(d.newSessionId.slice(0, 8)) + '</b></div>' +
|
|
4967
|
+
'<div class="fb-actions"><button class="fb-dismiss" id="fb-success-dismiss">OK</button></div>';
|
|
4968
|
+
document.getElementById('fb-success-dismiss').addEventListener('click', function() {
|
|
4969
|
+
fbDismissed = true;
|
|
4970
|
+
fbEl.style.display = 'none';
|
|
4971
|
+
});
|
|
4972
|
+
}).catch(function() {
|
|
4973
|
+
fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> Network error. Check if the daemon is running.</div>';
|
|
4974
|
+
});
|
|
4975
|
+
});
|
|
4976
|
+
});
|
|
4977
|
+
|
|
4978
|
+
// Wire dismiss buttons (initial render)
|
|
4979
|
+
fbEl.querySelectorAll('.fb-dismiss').forEach(function(btn) {
|
|
4980
|
+
btn.addEventListener('click', function() {
|
|
4981
|
+
fbDismissed = true;
|
|
4982
|
+
fbEl.style.display = 'none';
|
|
4983
|
+
});
|
|
4984
|
+
});
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
setInterval(function() {
|
|
4988
|
+
fetch('/api/failover-offers').then(function(r) { return r.json(); }).then(function(d) {
|
|
4989
|
+
renderFailoverOffers(d.offers);
|
|
4990
|
+
}).catch(function() { /* fine */ });
|
|
4991
|
+
}, 5000);
|
|
2526
4992
|
})();
|
|
2527
4993
|
</script>
|
|
2528
4994
|
</body>
|
|
@@ -2614,6 +5080,46 @@ ${SHARED_STYLES}
|
|
|
2614
5080
|
margin-top: 4px; padding-left: 22px;
|
|
2615
5081
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
2616
5082
|
}
|
|
5083
|
+
/* Pencil affordance for project rename. Always visible at subtle opacity;
|
|
5084
|
+
brightens only when the user hovers the pencil itself. NO background
|
|
5085
|
+
box at any state. Inline SVG so it renders crisp at any size and
|
|
5086
|
+
inherits color from CSS. */
|
|
5087
|
+
.channel-rename {
|
|
5088
|
+
background: transparent; border: none; cursor: pointer;
|
|
5089
|
+
color: var(--fg-30); padding: 4px;
|
|
5090
|
+
transition: color 0.12s ease;
|
|
5091
|
+
flex: 0 0 auto;
|
|
5092
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
5093
|
+
}
|
|
5094
|
+
.channel-rename svg { display: block; width: 14px; height: 14px; }
|
|
5095
|
+
.channel-rename:hover { color: var(--fg); background: transparent; }
|
|
5096
|
+
|
|
5097
|
+
/* Inline edit-in-place for project rename. May 6 2026: replaces the
|
|
5098
|
+
prior window.prompt() browser dialog (cheap UX; not enterprise-grade
|
|
5099
|
+
per Open-Agents reference). Input matches channel-name typography so
|
|
5100
|
+
the swap feels seamless. */
|
|
5101
|
+
.channel-rename-input {
|
|
5102
|
+
background: transparent;
|
|
5103
|
+
border: 1px solid var(--accent);
|
|
5104
|
+
border-radius: 3px;
|
|
5105
|
+
color: var(--fg);
|
|
5106
|
+
font: inherit;
|
|
5107
|
+
font-weight: 500;
|
|
5108
|
+
padding: 1px 4px;
|
|
5109
|
+
margin: -2px -5px;
|
|
5110
|
+
width: calc(100% + 10px);
|
|
5111
|
+
outline: none;
|
|
5112
|
+
box-sizing: border-box;
|
|
5113
|
+
}
|
|
5114
|
+
.channel-rename-input.error {
|
|
5115
|
+
border-color: var(--block);
|
|
5116
|
+
animation: rename-shake 0.25s ease;
|
|
5117
|
+
}
|
|
5118
|
+
@keyframes rename-shake {
|
|
5119
|
+
0%, 100% { transform: translateX(0); }
|
|
5120
|
+
25% { transform: translateX(-2px); }
|
|
5121
|
+
75% { transform: translateX(2px); }
|
|
5122
|
+
}
|
|
2617
5123
|
.pane-empty {
|
|
2618
5124
|
color: var(--fg-30); font-size: 10.5px;
|
|
2619
5125
|
padding: 8px; text-align: center;
|
|
@@ -2663,8 +5169,11 @@ ${SHARED_STYLES}
|
|
|
2663
5169
|
font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
|
2664
5170
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
2665
5171
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
5172
|
+
/* Pattern 8 ride-along revert: f830183 demoted .completed to muted neutral.
|
|
5173
|
+
Restoring green — actionable success is a load-bearing semantic signal
|
|
5174
|
+
in agentic UX. */
|
|
5175
|
+
.task .status.completed { background: rgba(40,200,64,0.10); color: var(--allow); border: 1px solid rgba(40,200,64,0.20); }
|
|
5176
|
+
.task .status.running { background: var(--accent-wash); color: var(--accent); border: 1px solid rgba(255,154,118,0.25); }
|
|
2668
5177
|
.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
5178
|
.task .status.abandoned { background: rgba(255,255,255,0.04); color: var(--fg-40); border: 1px solid var(--border); }
|
|
2670
5179
|
.task .ts { font-family: var(--mono); font-size: 11px; color: var(--fg-40); margin-left: auto; }
|
|
@@ -2675,6 +5184,26 @@ ${SHARED_STYLES}
|
|
|
2675
5184
|
.task .steps .step { padding: 2px 0; }
|
|
2676
5185
|
.task .steps .step.done::before { content: '✓ '; color: var(--allow); }
|
|
2677
5186
|
|
|
5187
|
+
/* Pattern 21 · Buffered reveal — cascade animation for completed steps */
|
|
5188
|
+
.task .steps.buffered-reveal .step {
|
|
5189
|
+
opacity: 0;
|
|
5190
|
+
transform: translateY(4px);
|
|
5191
|
+
animation: br-cascade 80ms ease-out forwards;
|
|
5192
|
+
}
|
|
5193
|
+
.task .steps.buffered-reveal .step:nth-child(1) { animation-delay: 0ms; }
|
|
5194
|
+
.task .steps.buffered-reveal .step:nth-child(2) { animation-delay: 80ms; }
|
|
5195
|
+
.task .steps.buffered-reveal .step:nth-child(3) { animation-delay: 160ms; }
|
|
5196
|
+
.task .steps.buffered-reveal .step:nth-child(4) { animation-delay: 240ms; }
|
|
5197
|
+
.task .steps.buffered-reveal .step:nth-child(5) { animation-delay: 320ms; }
|
|
5198
|
+
.task .steps.buffered-reveal .step:nth-child(6) { animation-delay: 400ms; }
|
|
5199
|
+
.task .steps.buffered-reveal .step:nth-child(7) { animation-delay: 480ms; }
|
|
5200
|
+
.task .steps.buffered-reveal .step:nth-child(8) { animation-delay: 560ms; }
|
|
5201
|
+
.task .steps.buffered-reveal .step:nth-child(9) { animation-delay: 640ms; }
|
|
5202
|
+
.task .steps.buffered-reveal .step:nth-child(10) { animation-delay: 720ms; }
|
|
5203
|
+
@keyframes br-cascade {
|
|
5204
|
+
to { opacity: 1; transform: translateY(0); }
|
|
5205
|
+
}
|
|
5206
|
+
|
|
2678
5207
|
.empty {
|
|
2679
5208
|
text-align: center; padding: 60px 24px; color: var(--fg-50); font-family: var(--mono); font-size: 13px;
|
|
2680
5209
|
}
|
|
@@ -2727,6 +5256,8 @@ ${SHARED_STYLES}
|
|
|
2727
5256
|
<a href="/" class="nav-link">Tasks</a>
|
|
2728
5257
|
<a href="/threads" class="nav-link active">Threads</a>
|
|
2729
5258
|
<a href="/history" class="nav-link">History</a>
|
|
5259
|
+
<a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
|
|
5260
|
+
<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
5261
|
</nav>
|
|
2731
5262
|
|
|
2732
5263
|
<div class="shell slack-shape">
|
|
@@ -2780,6 +5311,9 @@ ${SHARED_STYLES}
|
|
|
2780
5311
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2781
5312
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
2782
5313
|
}
|
|
5314
|
+
// Per-agent badge helpers shared with TASK_HTML / HISTORY_HTML — single
|
|
5315
|
+
// source of truth in AGENT_BADGE_JS at the top of this module.
|
|
5316
|
+
${AGENT_BADGE_JS}
|
|
2783
5317
|
function timeAgo(ts) {
|
|
2784
5318
|
if (!ts) return '';
|
|
2785
5319
|
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
@@ -2797,7 +5331,12 @@ ${SHARED_STYLES}
|
|
|
2797
5331
|
if (ms == null) return '';
|
|
2798
5332
|
if (ms < 1000) return ms + 'ms';
|
|
2799
5333
|
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
2800
|
-
return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
5334
|
+
if (ms < 3600000) return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
5335
|
+
if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
|
|
5336
|
+
// Days-scale: this is almost always a stale-running session that
|
|
5337
|
+
// got reconciled days later. Show "Nd" — actual elapsed runtime is
|
|
5338
|
+
// unknowable so don't pretend "108h 6m" carries meaning.
|
|
5339
|
+
return Math.floor(ms / 86400000) + 'd';
|
|
2801
5340
|
}
|
|
2802
5341
|
function shortPath(p) {
|
|
2803
5342
|
if (!p) return '~';
|
|
@@ -2888,20 +5427,48 @@ ${SHARED_STYLES}
|
|
|
2888
5427
|
return;
|
|
2889
5428
|
}
|
|
2890
5429
|
|
|
2891
|
-
|
|
5430
|
+
// v0.6 — render-cap to keep huge projects (685+ turns on the founder's
|
|
5431
|
+
// dev repo) from blowing the page to ~80,000px tall. "Show all" toggle
|
|
5432
|
+
// mirrors the dashboard 5-card cap pattern. Chronological order
|
|
5433
|
+
// preserved (Slack-shape: oldest first, recent at bottom).
|
|
5434
|
+
const RENDER_CAP = 25;
|
|
5435
|
+
const totalTurns = thread.tasks.length;
|
|
5436
|
+
const showAll = container.dataset.showAllTurns === 'true';
|
|
5437
|
+
const renderedTurns = showAll ? thread.tasks : thread.tasks.slice(-RENDER_CAP);
|
|
5438
|
+
const isCapped = !showAll && totalTurns > RENDER_CAP;
|
|
5439
|
+
|
|
5440
|
+
if (isCapped) {
|
|
5441
|
+
html += '<div class="section-label" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
|
|
5442
|
+
'<span>Thread · showing <b>' + renderedTurns.length + '</b> most recent of ' + totalTurns + ' turns</span>' +
|
|
5443
|
+
'<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>' +
|
|
5444
|
+
'</div>';
|
|
5445
|
+
} else {
|
|
5446
|
+
html += '<div class="section-label">Thread · ' + totalTurns + ' turn' + (totalTurns === 1 ? '' : 's') + '</div>';
|
|
5447
|
+
}
|
|
2892
5448
|
|
|
2893
|
-
for (const t of
|
|
5449
|
+
for (const t of renderedTurns) {
|
|
2894
5450
|
const status = (t.status || '').toLowerCase();
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
5451
|
+
// v0.6 — cap per-task steps at last 5 (mirrors dashboard renderSteps
|
|
5452
|
+
// tail-3 pattern). A long task with 100+ steps × 25 turns rendered
|
|
5453
|
+
// = 2500 step DOM nodes. Show last 5 + "+N more" pill.
|
|
5454
|
+
let stepsHtml = '';
|
|
5455
|
+
if (t.steps && t.steps.length) {
|
|
5456
|
+
const STEP_TAIL = 5;
|
|
5457
|
+
const stepTail = t.steps.slice(-STEP_TAIL);
|
|
5458
|
+
const stepHidden = t.steps.length - stepTail.length;
|
|
5459
|
+
stepsHtml = '<div class="steps">' +
|
|
5460
|
+
stepTail.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') +
|
|
5461
|
+
(stepHidden > 0 ? '<div class="step muted">+' + stepHidden + ' earlier step' + (stepHidden === 1 ? '' : 's') + '</div>' : '') +
|
|
5462
|
+
'</div>';
|
|
5463
|
+
}
|
|
2898
5464
|
const durHtml = (t.durationMs != null)
|
|
2899
5465
|
? '<div class="duration">Duration: <b>' + fmtDuration(t.durationMs) + '</b></div>'
|
|
2900
5466
|
: (status === 'running' ? '<div class="duration">Running…</div>' : '');
|
|
2901
5467
|
|
|
2902
5468
|
html += '<div class="task">' +
|
|
2903
5469
|
'<div class="row1">' +
|
|
2904
|
-
|
|
5470
|
+
agentBadgeHTML(t.agent || 'unknown') +
|
|
5471
|
+
' <span class="agent-name">' + escapeHtml(agentLabel(t.agent || 'unknown')) + '</span>' +
|
|
2905
5472
|
'<span class="status ' + escapeHtml(status) + '">' + escapeHtml(status || 'unknown') + '</span>' +
|
|
2906
5473
|
'<span class="ts">' + escapeHtml(fmtTime(t.startedAt)) + ' · ' + escapeHtml(timeAgo(t.startedAt)) + '</span>' +
|
|
2907
5474
|
'</div>' +
|
|
@@ -2911,6 +5478,16 @@ ${SHARED_STYLES}
|
|
|
2911
5478
|
'</div>';
|
|
2912
5479
|
}
|
|
2913
5480
|
container.innerHTML = html;
|
|
5481
|
+
// Wire the "Show all N turns" toggle (rendered only when capped). Sets
|
|
5482
|
+
// the dataset flag and re-runs renderThread with the same data so the
|
|
5483
|
+
// cap branch falls through.
|
|
5484
|
+
const showAllBtn = container.querySelector('.show-all-turns');
|
|
5485
|
+
if (showAllBtn) {
|
|
5486
|
+
showAllBtn.addEventListener('click', () => {
|
|
5487
|
+
container.dataset.showAllTurns = 'true';
|
|
5488
|
+
renderThread(thread);
|
|
5489
|
+
});
|
|
5490
|
+
}
|
|
2914
5491
|
const lastTask = thread.tasks[thread.tasks.length - 1];
|
|
2915
5492
|
setReplyState(lastTask.sessionId, lastTask.agent);
|
|
2916
5493
|
}
|
|
@@ -2925,16 +5502,21 @@ ${SHARED_STYLES}
|
|
|
2925
5502
|
const active = p.slug === currentSlug ? ' active' : '';
|
|
2926
5503
|
const name = escapeHtml(p.title || '(untitled)');
|
|
2927
5504
|
const cwdHint = escapeHtml(shortPath(p.cwd));
|
|
5505
|
+
// Pencil affordance — visible on hover via CSS .channel:hover .channel-rename.
|
|
5506
|
+
// Triggers prompt() for v1 simplicity; inline input UX is a future iteration.
|
|
2928
5507
|
return '<div class="channel' + active + '" data-slug="' + escapeHtml(p.slug) + '">' +
|
|
2929
5508
|
'<span class="channel-glyph">#</span>' +
|
|
2930
5509
|
'<div style="flex:1;min-width:0">' +
|
|
2931
5510
|
'<div class="channel-name">' + name + '</div>' +
|
|
2932
5511
|
'<div class="channel-cwd-hint">' + cwdHint + '</div>' +
|
|
2933
5512
|
'</div>' +
|
|
5513
|
+
'<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
5514
|
'</div>';
|
|
2935
5515
|
}).join('');
|
|
2936
5516
|
projectsList.querySelectorAll('.channel').forEach(el => {
|
|
2937
|
-
el.addEventListener('click', () => {
|
|
5517
|
+
el.addEventListener('click', (ev) => {
|
|
5518
|
+
// Skip channel-switch when the click was the rename button — that has its own handler.
|
|
5519
|
+
if (ev.target && (ev.target).classList && (ev.target).classList.contains('channel-rename')) return;
|
|
2938
5520
|
const slug = el.getAttribute('data-slug');
|
|
2939
5521
|
if (!slug || slug === currentSlug) return;
|
|
2940
5522
|
currentSlug = slug;
|
|
@@ -2945,6 +5527,72 @@ ${SHARED_STYLES}
|
|
|
2945
5527
|
history.replaceState(null, '', url.toString());
|
|
2946
5528
|
});
|
|
2947
5529
|
});
|
|
5530
|
+
projectsList.querySelectorAll('.channel-rename').forEach(btn => {
|
|
5531
|
+
btn.addEventListener('click', (ev) => {
|
|
5532
|
+
ev.stopPropagation(); // don't trigger channel-switch
|
|
5533
|
+
const slug = btn.getAttribute('data-slug');
|
|
5534
|
+
if (!slug) return;
|
|
5535
|
+
const channel = btn.closest('.channel');
|
|
5536
|
+
const project = list.find(p => p.slug === slug);
|
|
5537
|
+
const current = (project && project.title) || '';
|
|
5538
|
+
startInlineRename(channel, slug, current, () => {
|
|
5539
|
+
loadProjects();
|
|
5540
|
+
// Renamed project may be the visible one — refresh the main pane
|
|
5541
|
+
// header immediately instead of waiting for the 5s poll. Without
|
|
5542
|
+
// this, the sidebar updates but the open thread keeps the old title.
|
|
5543
|
+
if (slug === currentSlug) loadThread(slug);
|
|
5544
|
+
});
|
|
5545
|
+
});
|
|
5546
|
+
});
|
|
5547
|
+
}
|
|
5548
|
+
// Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
|
|
5549
|
+
// .channel-name turns into an input pre-filled with the title; Enter saves,
|
|
5550
|
+
// Esc cancels, blur saves. Validation errors flash the border red instead
|
|
5551
|
+
// of a window.alert. Per Open-Agents production-grade UX reference.
|
|
5552
|
+
function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
|
|
5553
|
+
if (!channelEl) return;
|
|
5554
|
+
const nameEl = channelEl.querySelector('.channel-name');
|
|
5555
|
+
if (!nameEl || nameEl.dataset.editing === '1') return;
|
|
5556
|
+
nameEl.dataset.editing = '1';
|
|
5557
|
+
const originalText = nameEl.textContent;
|
|
5558
|
+
const input = document.createElement('input');
|
|
5559
|
+
input.type = 'text';
|
|
5560
|
+
input.value = currentTitle;
|
|
5561
|
+
input.maxLength = 100;
|
|
5562
|
+
input.className = 'channel-rename-input';
|
|
5563
|
+
nameEl.textContent = '';
|
|
5564
|
+
nameEl.appendChild(input);
|
|
5565
|
+
setTimeout(() => { input.focus(); input.select(); }, 0);
|
|
5566
|
+
let cleanedUp = false;
|
|
5567
|
+
function cleanup(restoreText) {
|
|
5568
|
+
if (cleanedUp) return;
|
|
5569
|
+
cleanedUp = true;
|
|
5570
|
+
try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
|
|
5571
|
+
nameEl.textContent = restoreText;
|
|
5572
|
+
delete nameEl.dataset.editing;
|
|
5573
|
+
}
|
|
5574
|
+
function commit() {
|
|
5575
|
+
if (cleanedUp) return;
|
|
5576
|
+
const trimmed = input.value.trim();
|
|
5577
|
+
if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
|
|
5578
|
+
fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
|
|
5579
|
+
method: 'POST',
|
|
5580
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5581
|
+
body: JSON.stringify({ title: trimmed }),
|
|
5582
|
+
})
|
|
5583
|
+
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
|
|
5584
|
+
.then(() => { cleanup(trimmed); onSaveSuccess(); })
|
|
5585
|
+
.catch(() => {
|
|
5586
|
+
input.classList.remove('error');
|
|
5587
|
+
void input.offsetWidth;
|
|
5588
|
+
input.classList.add('error');
|
|
5589
|
+
});
|
|
5590
|
+
}
|
|
5591
|
+
input.addEventListener('keydown', (ev) => {
|
|
5592
|
+
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
5593
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
|
|
5594
|
+
});
|
|
5595
|
+
input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
|
|
2948
5596
|
}
|
|
2949
5597
|
|
|
2950
5598
|
function presenceClass(state) {
|
|
@@ -2966,6 +5614,10 @@ ${SHARED_STYLES}
|
|
|
2966
5614
|
|
|
2967
5615
|
function loadThread(slug) {
|
|
2968
5616
|
if (!slug) { container.innerHTML = '<div class="empty"><div class="pill">No project selected</div></div>'; setReplyState(null, null); return; }
|
|
5617
|
+
// Reset the show-all-turns flag on every project switch so a user
|
|
5618
|
+
// moving from a 686-turn project to a 5-turn one doesn't carry the
|
|
5619
|
+
// toggled state across.
|
|
5620
|
+
delete container.dataset.showAllTurns;
|
|
2969
5621
|
fetch('/api/projects/' + encodeURIComponent(slug))
|
|
2970
5622
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
|
2971
5623
|
.then(data => renderThread(data.thread))
|