codehere 0.5.0-alpha.4 → 0.5.0-alpha.6

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.
@@ -513,13 +513,16 @@ ${SHARED_STYLES}
513
513
  <nav class="floating-nav">
514
514
  <a href="/" class="logo">codehere</a>
515
515
  <a href="/" class="nav-link">Tasks</a>
516
+ <a href="/threads" class="nav-link">Threads</a>
516
517
  <a href="/history" class="nav-link active">History</a>
517
- <span style="color:var(--fg-40);font-family:var(--mono);font-size:11px;">
518
- <span id="live-dot" class="dot"></span><span id="conn">connecting</span>
519
- </span>
520
518
  </nav>
521
519
 
522
- <div class="tagline" style="margin-top:90px">One feed. Every agent. Every action.</div>
520
+ <div class="tagline" style="margin-top:90px">
521
+ One feed. Every agent. Every action.
522
+ <span class="conn-inline" style="margin-left:12px;font-family:var(--mono);font-size:10px;color:var(--fg-40);">
523
+ <span id="live-dot" class="dot"></span><span id="conn">connecting</span>
524
+ </span>
525
+ </div>
523
526
 
524
527
  <div class="status">
525
528
  <div class="stat">
@@ -864,6 +867,17 @@ ${SHARED_STYLES}
864
867
  }
865
868
  .intake button.spawn:hover { filter: brightness(1.05); }
866
869
  .intake button.spawn[disabled] { opacity: 0.45; cursor: default; }
870
+ /* v0.6.x Slack-shape spike — alternative submit that routes the prompt as a
871
+ reply to the most-recent task in the current cwd, exercising the existing
872
+ appendMessage path. Lets a single intake feel like an ongoing conversation
873
+ when the user wants continuity, while preserving "Assign" as the explicit
874
+ new-task path. Disabled when no thread exists for the current cwd. */
875
+ .intake button.continue-thread {
876
+ background: rgba(255,154,118,0.10); color: var(--accent); font-weight: 600;
877
+ border-color: rgba(255,154,118,0.3);
878
+ }
879
+ .intake button.continue-thread:hover:not([disabled]) { background: rgba(255,154,118,0.18); }
880
+ .intake button.continue-thread[disabled] { opacity: 0.4; cursor: default; }
867
881
  .intake .hint { margin-left: auto; color: var(--fg-40); font-family: var(--mono); font-size: 11px; }
868
882
 
869
883
  /* ── Setup panel (onboarding — appears when a non-ready agent is picked) ───
@@ -1196,6 +1210,7 @@ ${SHARED_STYLES}
1196
1210
  <nav class="floating-nav">
1197
1211
  <a href="/" class="logo">codehere</a>
1198
1212
  <a href="/" class="nav-link active">Tasks</a>
1213
+ <a href="/threads" class="nav-link">Threads</a>
1199
1214
  <a href="/history" class="nav-link">History</a>
1200
1215
  </nav>
1201
1216
 
@@ -1231,11 +1246,12 @@ ${SHARED_STYLES}
1231
1246
  <div class="intake-row">
1232
1247
  <select id="agent" title="Auto-pick agent · codehere routes. Override below."><option value="">Auto-pick agent</option></select>
1233
1248
  <input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
1234
- <button class="spawn" id="spawn">Assign</button>
1249
+ <button class="continue-thread" id="continue-thread" type="button" disabled title="Send as a reply to the most recent task in this folder (Slack-shape spike)">Continue thread</button>
1250
+ <button class="spawn" id="spawn">Assign new</button>
1235
1251
  </div>
1236
1252
  <div class="setup-panel" id="setup-panel" data-state=""></div>
1237
1253
  <div class="intake-row" style="margin-top:6px">
1238
- <span class="hint" style="margin-left:auto">⌘ + Enter</span>
1254
+ <span class="hint" style="margin-left:auto">⌘+Enter = Assign new · ⌘+Shift+Enter = Continue thread</span>
1239
1255
  </div>
1240
1256
  </div>
1241
1257
 
@@ -1839,7 +1855,11 @@ ${SHARED_STYLES}
1839
1855
  const replyStatusEl = card.querySelector('.reply-status');
1840
1856
  if (!session.canAppendMessage) {
1841
1857
  threadInputEl.classList.add('disabled');
1842
- replyTextEl.placeholder = 'Reply unsupported for ' + agentLabel(session.agent) + ' yet';
1858
+ // All adapters wire appendMessage as of v0.6.0 Slices 1-3. This branch
1859
+ // now only fires for sessions evicted from in-memory tracking (the
1860
+ // canAppendMessage flag persists from spawn but the API endpoint
1861
+ // requires the session to still be live in SessionManager).
1862
+ replyTextEl.placeholder = 'Replies require a live session — re-spawn to continue this thread';
1843
1863
  replyTextEl.disabled = true;
1844
1864
  replySendBtn.disabled = true;
1845
1865
  } else {
@@ -2079,13 +2099,22 @@ ${SHARED_STYLES}
2079
2099
  // Iter 12 — prose summary (3 paragraphs, plain English) prepended above
2080
2100
  // the artifact list. Non-technical users see prose first; raw artifact
2081
2101
  // detail (files/tests/tokens) below as supporting context.
2082
- function renderArtifacts(artifacts, finalReport, status) {
2102
+ function renderArtifacts(artifacts, finalReport, status, completedAt) {
2083
2103
  // Show the block if either prose summary OR artifacts exist OR if a
2084
2104
  // completed task is still generating its report (placeholder state).
2085
2105
  const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
2086
2106
  const hasContent = artifacts || finalReport;
2087
- const isAwaitingReport = isCompleted && !finalReport;
2088
- if (!hasContent && !isAwaitingReport) { finalReportEl.style.display = 'none'; return; }
2107
+ // v0.6.x — bound the "Generating report…" placeholder to the actual
2108
+ // generation window. Final Report typically returns within 30s; after
2109
+ // 90s post-completion with no report, the report either failed silently
2110
+ // or the daemon was restarted (in-memory finalReportBySession Map gets
2111
+ // wiped). Either way, "Generating report…" forever is dishonest.
2112
+ const REPORT_WAIT_BUDGET_MS = 90_000;
2113
+ const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
2114
+ const stillWaitingForReport = isCompleted && !finalReport && sinceCompleted < REPORT_WAIT_BUDGET_MS;
2115
+ const reportTimedOut = isCompleted && !finalReport && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
2116
+ const isAwaitingReport = stillWaitingForReport;
2117
+ if (!hasContent && !isAwaitingReport && !reportTimedOut) { finalReportEl.style.display = 'none'; return; }
2089
2118
  finalReportEl.style.display = 'block';
2090
2119
  const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
2091
2120
  const tests = artifacts?.tests;
@@ -2100,10 +2129,13 @@ ${SHARED_STYLES}
2100
2129
  paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
2101
2130
  '</div>';
2102
2131
  } else if (isAwaitingReport) {
2103
- // Placeholder — task done, report still generating (or provider failed silently).
2104
- // After ~30s with no report, the placeholder persists. Iter 13 will replace
2105
- // with explicit "report unavailable — start FCC proxy or set ANTHROPIC_API_KEY".
2106
2132
  html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
2133
+ } else if (reportTimedOut) {
2134
+ // Honest message instead of perpetual "Generating report…". Most
2135
+ // common cause: daemon restarted (Final Report is in-memory only,
2136
+ // doesn't persist to disk yet — v0.6.x cleanup item). Less common:
2137
+ // the report provider (claude-free / claude) failed silently.
2138
+ html += '<div class="fr-summary fr-summary-pending"><p>Final Report unavailable for this session. (Most often this means the daemon restarted — reports aren\\'t persisted yet. Re-spawn to get a fresh one.)</p></div>';
2107
2139
  }
2108
2140
  if (files.length > 0) {
2109
2141
  html += '<div class="fr-files">' + files.map(f => {
@@ -2141,7 +2173,7 @@ ${SHARED_STYLES}
2141
2173
  // Render Final Report block if there's anything to show (prose summary,
2142
2174
  // artifacts, or "generating…" placeholder for a freshly-completed session).
2143
2175
  if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
2144
- renderArtifacts(session.artifacts, session.finalReport, session.status);
2176
+ renderArtifacts(session.artifacts, session.finalReport, session.status, session.completedAt);
2145
2177
  }
2146
2178
 
2147
2179
  // Persisted sessions have no live event stream and no subprocess to kill.
@@ -2158,7 +2190,7 @@ ${SHARED_STYLES}
2158
2190
 
2159
2191
  // Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
2160
2192
  if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
2161
- if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status); return; }
2193
+ if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
2162
2194
 
2163
2195
  // Any structured error flips the status pill immediately — don't wait for
2164
2196
  // the subprocess to exit. Many CLIs print a logical error to stdout and
@@ -2228,10 +2260,95 @@ ${SHARED_STYLES}
2228
2260
  }
2229
2261
 
2230
2262
  spawnBtn.addEventListener('click', assignTask);
2263
+
2264
+ // v0.6.x Slack-shape spike — Continue thread button + Cmd+Shift+Enter.
2265
+ // Routes the prompt as a reply to the most recent in-memory session for
2266
+ // the current cwd via the existing /api/task/:id/message endpoint.
2267
+ // Disabled when no eligible session exists; auto-refresh state every 5s
2268
+ // alongside the existing tasks polling.
2269
+ const continueBtn = document.getElementById('continue-thread');
2270
+ let continueTargetSessionId = null;
2271
+
2272
+ function findContinueTarget(spawnedList, cwd) {
2273
+ if (!cwd) return null;
2274
+ const eligible = (spawnedList || []).filter(s => s.cwd === cwd && s.canAppendMessage === true);
2275
+ if (eligible.length === 0) return null;
2276
+ eligible.sort((a, b) => b.startedAt - a.startedAt);
2277
+ return eligible[0];
2278
+ }
2279
+
2280
+ function refreshContinueAvailability() {
2281
+ const cwd = cwdInput.value.trim();
2282
+ if (!cwd) {
2283
+ continueTargetSessionId = null;
2284
+ continueBtn.disabled = true;
2285
+ continueBtn.title = 'Set a folder first to continue its thread';
2286
+ return;
2287
+ }
2288
+ fetch('/api/tasks').then(r => r.json()).then(d => {
2289
+ const target = findContinueTarget(d.spawned, cwd);
2290
+ if (target) {
2291
+ continueTargetSessionId = target.sessionId;
2292
+ continueBtn.disabled = false;
2293
+ const promptPreview = (target.prompt || '').slice(0, 40);
2294
+ continueBtn.title = 'Reply to last task in this folder: "' + promptPreview + (target.prompt && target.prompt.length > 40 ? '…' : '') + '" (' + target.agent + ')';
2295
+ } else {
2296
+ continueTargetSessionId = null;
2297
+ continueBtn.disabled = true;
2298
+ continueBtn.title = 'No live session for this folder. Use Assign new instead.';
2299
+ }
2300
+ }).catch(() => {});
2301
+ }
2302
+
2303
+ async function continueThread() {
2304
+ const prompt = promptEl.value.trim();
2305
+ if (!prompt) return;
2306
+ if (!continueTargetSessionId) { alert('No live session for this folder. Use Assign new instead.'); return; }
2307
+ const targetId = continueTargetSessionId;
2308
+ continueBtn.disabled = true;
2309
+ const originalText = continueBtn.textContent;
2310
+ continueBtn.textContent = '...';
2311
+ try {
2312
+ const res = await fetch('/api/task/' + encodeURIComponent(targetId) + '/message', {
2313
+ method: 'POST',
2314
+ headers: { 'Content-Type': 'application/json' },
2315
+ body: JSON.stringify({ text: prompt }),
2316
+ });
2317
+ const data = await res.json();
2318
+ if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
2319
+ promptEl.value = '';
2320
+ // Scroll the existing card into view; existing 5s polling refreshes content.
2321
+ const card = document.querySelector('[data-session-id="' + targetId + '"]');
2322
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2323
+ } catch (e) {
2324
+ alert('Network error: ' + (e && e.message ? e.message : e));
2325
+ } finally {
2326
+ continueBtn.disabled = false;
2327
+ continueBtn.textContent = originalText;
2328
+ // Re-check availability (target may have settled differently)
2329
+ refreshContinueAvailability();
2330
+ }
2331
+ }
2332
+
2333
+ continueBtn.addEventListener('click', continueThread);
2334
+ cwdInput.addEventListener('input', refreshContinueAvailability);
2335
+ cwdInput.addEventListener('change', refreshContinueAvailability);
2336
+
2231
2337
  promptEl.addEventListener('keydown', (e) => {
2232
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); assignTask(); }
2338
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
2339
+ e.preventDefault();
2340
+ if (e.shiftKey) {
2341
+ continueThread();
2342
+ } else {
2343
+ assignTask();
2344
+ }
2345
+ }
2233
2346
  });
2234
2347
 
2348
+ // Refresh continue-target availability alongside the existing tasks polling
2349
+ setInterval(refreshContinueAvailability, 5000);
2350
+ refreshContinueAvailability();
2351
+
2235
2352
  // ── Observed sessions (F1d) ──────────────────────────────────────────
2236
2353
  function shortId(id) { return String(id || '').slice(0, 8); }
2237
2354
 
@@ -2365,7 +2482,7 @@ ${SHARED_STYLES}
2365
2482
  for (const s of (d.spawned || d.sessions || [])) {
2366
2483
  const entry = sessions.get(s.sessionId);
2367
2484
  if (entry && entry.renderArtifacts) {
2368
- entry.renderArtifacts(s.artifacts, s.finalReport, s.status);
2485
+ entry.renderArtifacts(s.artifacts, s.finalReport, s.status, s.completedAt);
2369
2486
  }
2370
2487
  }
2371
2488
  }).catch(() => { /* fine */ });
@@ -2410,4 +2527,486 @@ ${SHARED_STYLES}
2410
2527
  </script>
2411
2528
  </body>
2412
2529
  </html>`;
2530
+ /**
2531
+ * Project thread view — v0.6.0 M1 phase 1.
2532
+ *
2533
+ * Reads from /api/projects + /api/projects/:slug (shipped in 397a66a) and
2534
+ * renders a single per-project thread instead of session-card-per-task.
2535
+ * Phase 2 (next) promotes this to the dashboard default; this phase ships
2536
+ * it as a navigable surface so it can be tasted without breaking the
2537
+ * existing dashboard.
2538
+ *
2539
+ * Polling: 5s interval (matches existing /api/tasks polling cadence).
2540
+ * SSE upgrade is a v0.6.x patch.
2541
+ */
2542
+ export const THREAD_HTML = `<!doctype html>
2543
+ <html lang="en">
2544
+ <head>
2545
+ <meta charset="utf-8" />
2546
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
2547
+ <title>codehere · Threads</title>
2548
+ <style>
2549
+ ${SHARED_STYLES}
2550
+ * { box-sizing: border-box; }
2551
+ html, body { height: 100%; }
2552
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
2553
+
2554
+ /* Floating nav inherits from SHARED_STYLES (no override here — keeps the
2555
+ active-state look identical to / and /history. Founder caught
2556
+ 2026-05-03 that the local override gave /threads a different orange-bg
2557
+ active state, fragmenting the design system across pages.) */
2558
+
2559
+ /* ── Slack-shape shell (3-column — channels | thread | members) ─────── */
2560
+ .shell.slack-shape {
2561
+ max-width: 1440px;
2562
+ margin: 90px auto 24px;
2563
+ padding: 0 24px;
2564
+ display: grid;
2565
+ grid-template-columns: 200px 1fr 220px;
2566
+ gap: 16px;
2567
+ align-items: start;
2568
+ }
2569
+ @media (max-width: 1100px) {
2570
+ .shell.slack-shape { grid-template-columns: 1fr; }
2571
+ .shell.slack-shape .channels-pane, .shell.slack-shape .members-pane {
2572
+ position: static; order: 99;
2573
+ }
2574
+ }
2575
+ .channels-pane, .members-pane {
2576
+ background: rgba(255,255,255,0.025);
2577
+ border: 1px solid var(--border-soft);
2578
+ border-radius: 10px;
2579
+ padding: 14px 12px;
2580
+ position: sticky; top: 88px;
2581
+ font-family: var(--mono);
2582
+ z-index: 1;
2583
+ }
2584
+ .pane-head {
2585
+ font-size: 9.5px; letter-spacing: 0.16em;
2586
+ color: var(--fg-30); margin-bottom: 10px;
2587
+ text-transform: uppercase;
2588
+ display: flex; align-items: center; justify-content: space-between;
2589
+ }
2590
+ .pane-head .pane-count { color: var(--fg-50); }
2591
+ .channel, .member {
2592
+ display: flex; align-items: center; gap: 8px;
2593
+ padding: 6px 8px; border-radius: 6px;
2594
+ font-size: 11.5px;
2595
+ cursor: pointer;
2596
+ color: var(--fg-70);
2597
+ border: 1px solid transparent;
2598
+ margin-bottom: 2px;
2599
+ }
2600
+ .channel:hover, .member:hover { background: rgba(255,255,255,0.04); color: var(--fg); }
2601
+ .channel.active { background: rgba(255,154,118,0.08); color: var(--fg); border-color: rgba(255,154,118,0.25); }
2602
+ .channel-glyph, .member-presence {
2603
+ color: var(--fg-40); flex: 0 0 14px; text-align: center;
2604
+ }
2605
+ .member-presence.ready { color: var(--allow); }
2606
+ .member-presence.warn { color: var(--warn); }
2607
+ .member-presence.offline { color: var(--fg-30); }
2608
+ .channel-name, .member-label {
2609
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2610
+ flex: 1; min-width: 0;
2611
+ }
2612
+ .member-cwd-hint, .channel-cwd-hint {
2613
+ font-size: 9.5px; color: var(--fg-30);
2614
+ margin-top: 4px; padding-left: 22px;
2615
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2616
+ }
2617
+ .pane-empty {
2618
+ color: var(--fg-30); font-size: 10.5px;
2619
+ padding: 8px; text-align: center;
2620
+ }
2621
+
2622
+ /* ── Thread pane (center) ───────────────────────────────────────────── */
2623
+ .thread-pane { min-width: 0; }
2624
+
2625
+ .meta-panel {
2626
+ border: 1px solid var(--border); background: rgba(255,255,255,0.02);
2627
+ border-radius: 10px; padding: 18px 20px; margin-bottom: 16px;
2628
+ }
2629
+ .meta-panel h1 {
2630
+ font-size: 22px; font-weight: 600; color: var(--fg);
2631
+ margin: 0 0 6px 0; line-height: 1.3;
2632
+ }
2633
+ .meta-panel h1.untitled { color: var(--fg-40); font-style: italic; font-weight: 400; }
2634
+ .meta-panel .cwd {
2635
+ font-family: var(--mono); font-size: 11px; color: var(--fg-50);
2636
+ margin-bottom: 12px; word-break: break-all;
2637
+ }
2638
+ .meta-panel .stats {
2639
+ display: flex; gap: 22px; font-family: var(--mono); font-size: 11px; color: var(--fg-50);
2640
+ flex-wrap: wrap;
2641
+ }
2642
+ .meta-panel .stats .stat { display: flex; gap: 6px; }
2643
+ .meta-panel .stats .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
2644
+ .meta-panel .stats .stat .val { color: var(--fg); font-weight: 600; }
2645
+
2646
+ .section-label {
2647
+ padding: 8px 4px;
2648
+ font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
2649
+ color: var(--fg-40); text-transform: uppercase;
2650
+ }
2651
+
2652
+ /* ── Task entries ───────────────────────────────────────────────────── */
2653
+ .task {
2654
+ border: 1px solid var(--border); background: rgba(255,255,255,0.015);
2655
+ border-radius: 8px; padding: 14px 16px; margin-bottom: 10px;
2656
+ }
2657
+ .task .row1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
2658
+ .task .agent {
2659
+ font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
2660
+ background: rgba(120, 180, 255, 0.10); color: #9bbfff; border: 1px solid rgba(120, 180, 255, 0.18);
2661
+ }
2662
+ .task .status {
2663
+ font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
2664
+ text-transform: uppercase; letter-spacing: 0.05em;
2665
+ }
2666
+ .task .status.completed { background: rgba(40, 200, 64, 0.10); color: var(--allow); border: 1px solid rgba(40, 200, 64, 0.18); }
2667
+ .task .status.running { background: rgba(255, 200, 0, 0.10); color: var(--warn); border: 1px solid rgba(255, 200, 0, 0.18); }
2668
+ .task .status.error, .task .status.failed { background: rgba(220, 60, 60, 0.10); color: var(--block); border: 1px solid rgba(220, 60, 60, 0.18); }
2669
+ .task .status.abandoned { background: rgba(255,255,255,0.04); color: var(--fg-40); border: 1px solid var(--border); }
2670
+ .task .ts { font-family: var(--mono); font-size: 11px; color: var(--fg-40); margin-left: auto; }
2671
+ .task .prompt { color: var(--fg); font-size: 13px; line-height: 1.5; word-wrap: break-word; }
2672
+ .task .duration { font-family: var(--mono); font-size: 11px; color: var(--fg-50); margin-top: 8px; }
2673
+ .task .duration b { color: var(--fg-70); font-weight: 600; }
2674
+ .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); }
2675
+ .task .steps .step { padding: 2px 0; }
2676
+ .task .steps .step.done::before { content: '✓ '; color: var(--allow); }
2677
+
2678
+ .empty {
2679
+ text-align: center; padding: 60px 24px; color: var(--fg-50); font-family: var(--mono); font-size: 13px;
2680
+ }
2681
+ .empty .pill {
2682
+ display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: 16px;
2683
+ margin-bottom: 12px; font-size: 11px;
2684
+ }
2685
+ .empty .hint { font-size: 12px; color: var(--fg-40); }
2686
+ .empty a { color: var(--accent); }
2687
+
2688
+ /* ── Reply bar at thread bottom (Slack-shape) ───────────────────────── */
2689
+ .reply-bar {
2690
+ margin-top: 16px;
2691
+ border: 1px solid var(--border); background: rgba(255,255,255,0.02);
2692
+ border-radius: 10px; padding: 12px 14px;
2693
+ }
2694
+ .reply-bar.disabled { opacity: 0.6; }
2695
+ .reply-bar .reply-label {
2696
+ font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.16em;
2697
+ color: var(--fg-40); text-transform: uppercase; margin-bottom: 8px;
2698
+ }
2699
+ .reply-bar textarea {
2700
+ width: 100%; min-height: 60px; padding: 10px 12px;
2701
+ background: var(--bg-2); color: var(--fg); border: 1px solid var(--border);
2702
+ border-radius: 6px; font-family: var(--sans); font-size: 13px; line-height: 1.5;
2703
+ resize: vertical; outline: none;
2704
+ }
2705
+ .reply-bar textarea:focus { border-color: var(--accent); }
2706
+ .reply-bar textarea::placeholder { color: var(--fg-30); }
2707
+ .reply-bar .row {
2708
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
2709
+ margin-top: 8px;
2710
+ }
2711
+ .reply-bar .hint { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
2712
+ .reply-bar button {
2713
+ background: rgba(255,154,118,0.10); color: var(--accent); border: 1px solid rgba(255,154,118,0.3);
2714
+ padding: 6px 16px; border-radius: 6px; font-family: var(--mono); font-size: 12px; cursor: pointer;
2715
+ }
2716
+ .reply-bar button:hover { background: rgba(255,154,118,0.18); }
2717
+ .reply-bar button:disabled { opacity: 0.4; cursor: default; }
2718
+ .reply-bar .status { font-family: var(--mono); font-size: 11px; color: var(--fg-50); margin-top: 8px; }
2719
+ .reply-bar .status.error { color: var(--block); }
2720
+
2721
+ code { font-family: var(--mono); background: rgba(255,255,255,0.05); padding: 1px 6px; border-radius: 4px; }
2722
+ </style>
2723
+ </head>
2724
+ <body>
2725
+ <nav class="floating-nav">
2726
+ <a href="/" class="logo">codehere</a>
2727
+ <a href="/" class="nav-link">Tasks</a>
2728
+ <a href="/threads" class="nav-link active">Threads</a>
2729
+ <a href="/history" class="nav-link">History</a>
2730
+ </nav>
2731
+
2732
+ <div class="shell slack-shape">
2733
+
2734
+ <aside class="channels-pane">
2735
+ <div class="pane-head"><span>Projects</span><span class="pane-count" id="projects-count">0</span></div>
2736
+ <div id="projects-list"><div class="pane-empty">Loading…</div></div>
2737
+ </aside>
2738
+
2739
+ <section class="thread-pane">
2740
+ <div id="thread-container"><div class="empty"><div class="pill">No project selected</div></div></div>
2741
+
2742
+ <!-- v0.6.0 Slice 4 — single reply input at thread bottom (Slack-shape).
2743
+ Routes to the LAST task's session in the thread via POST /api/task/<lastSessionId>/message. -->
2744
+ <div class="reply-bar disabled" id="reply-bar" style="display:none">
2745
+ <div class="reply-label">Reply to thread</div>
2746
+ <textarea id="reply-text" placeholder="Reply to this thread… (Cmd+Enter / Ctrl+Enter to send)" rows="2" disabled></textarea>
2747
+ <div class="row">
2748
+ <span class="hint" id="reply-hint">No active session — spawn a task first</span>
2749
+ <button type="button" id="reply-send" disabled>Send</button>
2750
+ </div>
2751
+ <div class="status" id="reply-status" style="display:none"></div>
2752
+ </div>
2753
+ </section>
2754
+
2755
+ <aside class="members-pane">
2756
+ <div class="pane-head"><span>Agents</span><span class="pane-count" id="agents-count">0</span></div>
2757
+ <div id="agents-list"><div class="pane-empty">Loading…</div></div>
2758
+ </aside>
2759
+
2760
+ </div>
2761
+
2762
+ <script>
2763
+ (function () {
2764
+ const projectsList = document.getElementById('projects-list');
2765
+ const projectsCount = document.getElementById('projects-count');
2766
+ const agentsList = document.getElementById('agents-list');
2767
+ const agentsCount = document.getElementById('agents-count');
2768
+ const container = document.getElementById('thread-container');
2769
+ const replyBar = document.getElementById('reply-bar');
2770
+ const replyText = document.getElementById('reply-text');
2771
+ const replySend = document.getElementById('reply-send');
2772
+ const replyHint = document.getElementById('reply-hint');
2773
+ const replyStatus = document.getElementById('reply-status');
2774
+
2775
+ let currentSlug = null;
2776
+ let lastSessionId = null;
2777
+
2778
+ function escapeHtml(s) {
2779
+ return String(s == null ? '' : s)
2780
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2781
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
2782
+ }
2783
+ function timeAgo(ts) {
2784
+ if (!ts) return '';
2785
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
2786
+ if (s < 60) return s + 's ago';
2787
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
2788
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
2789
+ return Math.floor(s / 86400) + 'd ago';
2790
+ }
2791
+ function fmtTime(ts) {
2792
+ if (!ts) return '';
2793
+ const d = new Date(ts);
2794
+ return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
2795
+ }
2796
+ function fmtDuration(ms) {
2797
+ if (ms == null) return '';
2798
+ if (ms < 1000) return ms + 'ms';
2799
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
2800
+ return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
2801
+ }
2802
+ function shortPath(p) {
2803
+ if (!p) return '~';
2804
+ const parts = p.split('/');
2805
+ return parts.length <= 3 ? p : '…/' + parts.slice(-2).join('/');
2806
+ }
2807
+
2808
+ function setReplyState(sessionId, agent) {
2809
+ lastSessionId = sessionId;
2810
+ if (sessionId) {
2811
+ replyBar.style.display = 'block';
2812
+ replyBar.classList.remove('disabled');
2813
+ replyText.disabled = false;
2814
+ replySend.disabled = false;
2815
+ replyHint.textContent = 'Replying to last task (' + (agent || 'unknown') + ')';
2816
+ } else {
2817
+ replyBar.style.display = 'block';
2818
+ replyBar.classList.add('disabled');
2819
+ replyText.disabled = true;
2820
+ replySend.disabled = true;
2821
+ replyHint.textContent = 'No active session — spawn a task first';
2822
+ }
2823
+ }
2824
+
2825
+ async function sendReply() {
2826
+ const text = (replyText.value || '').trim();
2827
+ if (!text || !lastSessionId) return;
2828
+ replySend.disabled = true;
2829
+ replySend.textContent = '…';
2830
+ replyStatus.style.display = 'none';
2831
+ replyStatus.classList.remove('error');
2832
+ try {
2833
+ const r = await fetch('/api/task/' + encodeURIComponent(lastSessionId) + '/message', {
2834
+ method: 'POST',
2835
+ headers: { 'Content-Type': 'application/json' },
2836
+ body: JSON.stringify({ text }),
2837
+ });
2838
+ if (!r.ok) {
2839
+ const data = await r.json().catch(() => ({}));
2840
+ throw new Error(data.error || ('HTTP ' + r.status));
2841
+ }
2842
+ const data = await r.json();
2843
+ replyText.value = '';
2844
+ replyStatus.style.display = 'block';
2845
+ replyStatus.textContent = 'Reply sent in ' + fmtDuration(data.durationMs) + ' — refreshing thread…';
2846
+ if (currentSlug) loadThread(currentSlug);
2847
+ } catch (err) {
2848
+ replyStatus.style.display = 'block';
2849
+ replyStatus.classList.add('error');
2850
+ replyStatus.textContent = 'Send failed: ' + (err && err.message ? err.message : err);
2851
+ } finally {
2852
+ replySend.disabled = false;
2853
+ replySend.textContent = 'Send';
2854
+ }
2855
+ }
2856
+
2857
+ replySend.addEventListener('click', sendReply);
2858
+ replyText.addEventListener('keydown', (e) => {
2859
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); sendReply(); }
2860
+ });
2861
+
2862
+ function renderThread(thread) {
2863
+ if (!thread) {
2864
+ container.innerHTML = '<div class="empty"><div class="pill">Project not found</div></div>';
2865
+ setReplyState(null, null);
2866
+ return;
2867
+ }
2868
+ const titleHtml = thread.title
2869
+ ? '<h1>' + escapeHtml(thread.title) + '</h1>'
2870
+ : '<h1 class="untitled">(untitled — Title agent will name this on first new spawn)</h1>';
2871
+ const cwdHtml = thread.cwd ? '<div class="cwd">' + escapeHtml(thread.cwd) + '</div>' : '';
2872
+ const lastActivity = thread.lastActivityAt ? fmtTime(thread.lastActivityAt) + ' · ' + timeAgo(thread.lastActivityAt) : '—';
2873
+
2874
+ let html = '<div class="meta-panel">' +
2875
+ titleHtml +
2876
+ cwdHtml +
2877
+ '<div class="stats">' +
2878
+ '<div class="stat"><span class="label">Tasks</span> <span class="val">' + thread.taskCount + '</span></div>' +
2879
+ '<div class="stat"><span class="label">Last activity</span> <span class="val">' + escapeHtml(lastActivity) + '</span></div>' +
2880
+ '<div class="stat"><span class="label">Slug</span> <span class="val">' + escapeHtml(thread.slug) + '</span></div>' +
2881
+ '</div>' +
2882
+ '</div>';
2883
+
2884
+ if (!thread.tasks || thread.tasks.length === 0) {
2885
+ 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>';
2886
+ container.innerHTML = html;
2887
+ setReplyState(null, null);
2888
+ return;
2889
+ }
2890
+
2891
+ html += '<div class="section-label">Thread · ' + thread.tasks.length + ' turn' + (thread.tasks.length === 1 ? '' : 's') + '</div>';
2892
+
2893
+ for (const t of thread.tasks) {
2894
+ const status = (t.status || '').toLowerCase();
2895
+ const stepsHtml = (t.steps && t.steps.length)
2896
+ ? '<div class="steps">' + t.steps.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') + '</div>'
2897
+ : '';
2898
+ const durHtml = (t.durationMs != null)
2899
+ ? '<div class="duration">Duration: <b>' + fmtDuration(t.durationMs) + '</b></div>'
2900
+ : (status === 'running' ? '<div class="duration">Running…</div>' : '');
2901
+
2902
+ html += '<div class="task">' +
2903
+ '<div class="row1">' +
2904
+ '<span class="agent">' + escapeHtml(t.agent || 'unknown') + '</span>' +
2905
+ '<span class="status ' + escapeHtml(status) + '">' + escapeHtml(status || 'unknown') + '</span>' +
2906
+ '<span class="ts">' + escapeHtml(fmtTime(t.startedAt)) + ' · ' + escapeHtml(timeAgo(t.startedAt)) + '</span>' +
2907
+ '</div>' +
2908
+ '<div class="prompt">' + escapeHtml(t.prompt || '(no prompt)') + '</div>' +
2909
+ durHtml +
2910
+ stepsHtml +
2911
+ '</div>';
2912
+ }
2913
+ container.innerHTML = html;
2914
+ const lastTask = thread.tasks[thread.tasks.length - 1];
2915
+ setReplyState(lastTask.sessionId, lastTask.agent);
2916
+ }
2917
+
2918
+ function renderProjects(list) {
2919
+ projectsCount.textContent = String(list.length);
2920
+ if (list.length === 0) {
2921
+ projectsList.innerHTML = '<div class="pane-empty">No projects yet</div>';
2922
+ return;
2923
+ }
2924
+ projectsList.innerHTML = list.map(p => {
2925
+ const active = p.slug === currentSlug ? ' active' : '';
2926
+ const name = escapeHtml(p.title || '(untitled)');
2927
+ const cwdHint = escapeHtml(shortPath(p.cwd));
2928
+ return '<div class="channel' + active + '" data-slug="' + escapeHtml(p.slug) + '">' +
2929
+ '<span class="channel-glyph">#</span>' +
2930
+ '<div style="flex:1;min-width:0">' +
2931
+ '<div class="channel-name">' + name + '</div>' +
2932
+ '<div class="channel-cwd-hint">' + cwdHint + '</div>' +
2933
+ '</div>' +
2934
+ '</div>';
2935
+ }).join('');
2936
+ projectsList.querySelectorAll('.channel').forEach(el => {
2937
+ el.addEventListener('click', () => {
2938
+ const slug = el.getAttribute('data-slug');
2939
+ if (!slug || slug === currentSlug) return;
2940
+ currentSlug = slug;
2941
+ renderProjects(list); // re-highlight active
2942
+ loadThread(slug);
2943
+ const url = new URL(window.location.href);
2944
+ url.searchParams.set('project', slug);
2945
+ history.replaceState(null, '', url.toString());
2946
+ });
2947
+ });
2948
+ }
2949
+
2950
+ function presenceClass(state) {
2951
+ if (state === 'ready') return 'ready';
2952
+ if (state === 'needs_install' || state === 'needs_auth' || state === 'billed_key_required') return 'warn';
2953
+ return 'offline';
2954
+ }
2955
+
2956
+ function renderAgents(adapters) {
2957
+ agentsCount.textContent = String(adapters.length);
2958
+ agentsList.innerHTML = adapters.map(a => {
2959
+ const cls = presenceClass(a.state);
2960
+ return '<div class="member">' +
2961
+ '<span class="member-presence ' + cls + '">●</span>' +
2962
+ '<span class="member-label">' + escapeHtml(a.label || a.id) + '</span>' +
2963
+ '</div>';
2964
+ }).join('');
2965
+ }
2966
+
2967
+ function loadThread(slug) {
2968
+ if (!slug) { container.innerHTML = '<div class="empty"><div class="pill">No project selected</div></div>'; setReplyState(null, null); return; }
2969
+ fetch('/api/projects/' + encodeURIComponent(slug))
2970
+ .then(r => r.ok ? r.json() : Promise.reject(r.status))
2971
+ .then(data => renderThread(data.thread))
2972
+ .catch(err => { container.innerHTML = '<div class="empty"><div class="pill">Load error: ' + escapeHtml(String(err)) + '</div></div>'; });
2973
+ }
2974
+
2975
+ function loadProjects() {
2976
+ fetch('/api/projects')
2977
+ .then(r => r.ok ? r.json() : Promise.reject(r.status))
2978
+ .then(data => {
2979
+ const list = data.projects || [];
2980
+ renderProjects(list);
2981
+ if (list.length > 0 && !currentSlug) {
2982
+ const urlSlug = new URLSearchParams(window.location.search).get('project');
2983
+ const slugToUse = (urlSlug && list.some(p => p.slug === urlSlug)) ? urlSlug : list[0].slug;
2984
+ currentSlug = slugToUse;
2985
+ renderProjects(list);
2986
+ loadThread(slugToUse);
2987
+ } else if (list.length === 0) {
2988
+ 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>';
2989
+ setReplyState(null, null);
2990
+ }
2991
+ })
2992
+ .catch(() => {});
2993
+ }
2994
+
2995
+ function loadAgents() {
2996
+ fetch('/api/readiness/agents')
2997
+ .then(r => r.ok ? r.json() : Promise.reject(r.status))
2998
+ .then(data => renderAgents(data.agents || []))
2999
+ .catch(() => {});
3000
+ }
3001
+
3002
+ loadProjects();
3003
+ loadAgents();
3004
+ setInterval(() => {
3005
+ if (currentSlug) loadThread(currentSlug);
3006
+ if (Math.random() < 0.25) { loadProjects(); loadAgents(); }
3007
+ }, 5000);
3008
+ })();
3009
+ </script>
3010
+ </body>
3011
+ </html>`;
2413
3012
  //# sourceMappingURL=ui-page.js.map