codehere 0.8.0 → 1.0.2

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