claude-pager 0.3.21 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels/ntfy/provider.d.ts.map +1 -1
- package/dist/channels/ntfy/provider.js +11 -2
- package/dist/channels/ntfy/provider.js.map +1 -1
- package/dist/channels/telegram/provider.d.ts.map +1 -1
- package/dist/channels/telegram/provider.js +35 -10
- package/dist/channels/telegram/provider.js.map +1 -1
- package/dist/daemon/__tests__/server.test.js +27 -1
- package/dist/daemon/__tests__/server.test.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +24 -4
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/terminal.d.ts +2 -0
- package/dist/daemon/terminal.d.ts.map +1 -1
- package/dist/daemon/terminal.js +22 -0
- package/dist/daemon/terminal.js.map +1 -1
- package/dist/dashboard/html.d.ts +1 -1
- package/dist/dashboard/html.d.ts.map +1 -1
- package/dist/dashboard/html.js +269 -22
- package/dist/dashboard/html.js.map +1 -1
- package/dist/utils/__tests__/ask-user-question.test.d.ts +2 -0
- package/dist/utils/__tests__/ask-user-question.test.d.ts.map +1 -0
- package/dist/utils/__tests__/ask-user-question.test.js +75 -0
- package/dist/utils/__tests__/ask-user-question.test.js.map +1 -0
- package/dist/utils/ask-user-question.d.ts +13 -0
- package/dist/utils/ask-user-question.d.ts.map +1 -0
- package/dist/utils/ask-user-question.js +65 -0
- package/dist/utils/ask-user-question.js.map +1 -0
- package/package.json +1 -1
package/dist/dashboard/html.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>claude-pager dashboard</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css\">\n <script src=\"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js\"></script>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n background: #0d1117;\n color: #c9d1d9;\n font-family: 'JetBrains Mono', monospace;\n min-height: 100vh;\n padding: 24px;\n }\n\n header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 32px;\n padding-bottom: 16px;\n border-bottom: 1px solid #21262d;\n }\n\n .logo {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .logo h1 {\n font-size: 22px;\n font-weight: 700;\n color: #f0f6fc;\n }\n\n .cursor {\n display: inline-block;\n width: 10px;\n height: 20px;\n background: #58a6ff;\n animation: blink 1s step-end infinite;\n vertical-align: middle;\n margin-left: 4px;\n }\n\n @keyframes blink {\n 50% { opacity: 0; }\n }\n\n .status-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n }\n\n .status-dot.connected { background: #3fb950; box-shadow: 0 0 6px #3fb950; }\n .status-dot.disconnected { background: #f85149; box-shadow: 0 0 6px #f85149; }\n\n .meta {\n font-size: 12px;\n color: #484f58;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .project {\n margin-bottom: 28px;\n }\n\n .project-header {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 12px;\n }\n\n .project-header h2 {\n font-size: 16px;\n font-weight: 600;\n color: #58a6ff;\n }\n\n .project-count {\n background: #21262d;\n color: #8b949e;\n font-size: 11px;\n padding: 2px 8px;\n border-radius: 10px;\n }\n\n .project-path {\n font-size: 11px;\n color: #484f58;\n margin-left: auto;\n }\n\n .pin-btn {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 14px;\n opacity: 0.3;\n transition: opacity 0.2s;\n padding: 2px 4px;\n }\n\n .pin-btn:hover { opacity: 0.7; }\n .pin-btn.pinned { opacity: 1; }\n\n .dismiss-btn {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 12px;\n opacity: 0.25;\n transition: opacity 0.2s;\n padding: 2px 4px;\n }\n\n .dismiss-btn:hover { opacity: 0.8; color: #f85149; }\n\n .ci-row {\n display: flex;\n gap: 12px;\n margin-bottom: 12px;\n font-size: 11px;\n }\n\n .ci-badge {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 3px 10px;\n border-radius: 12px;\n font-weight: 600;\n text-decoration: none;\n transition: opacity 0.2s;\n }\n\n .ci-badge:hover { opacity: 0.8; }\n\n .ci-badge.success { background: #0d2818; color: #3fb950; }\n .ci-badge.failed { background: #490202; color: #f85149; }\n .ci-badge.running { background: #0d419d; color: #58a6ff; animation: pulse 2s ease-in-out infinite; }\n .ci-badge.pending { background: #3d2e00; color: #d29922; }\n .ci-badge.canceled { background: #21262d; color: #8b949e; }\n .ci-badge.unknown { background: #21262d; color: #484f58; }\n\n .ci-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n display: inline-block;\n }\n\n .ci-dot.success { background: #3fb950; }\n .ci-dot.failed { background: #f85149; }\n .ci-dot.running { background: #58a6ff; }\n .ci-dot.pending { background: #d29922; }\n .ci-dot.canceled { background: #8b949e; }\n .ci-dot.unknown { background: #484f58; }\n\n .sessions {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));\n gap: 12px;\n }\n\n .card {\n background: #161b22;\n border: 1px solid #21262d;\n border-radius: 8px;\n padding: 16px;\n transition: border-color 0.2s, box-shadow 0.2s, opacity 0.3s;\n }\n\n .card:hover {\n border-color: #388bfd44;\n box-shadow: 0 0 12px #388bfd22;\n }\n\n .card.stale {\n opacity: 0.45;\n border-style: dashed;\n }\n\n .card.stale:hover {\n opacity: 0.8;\n }\n\n .card.active {\n border-color: #388bfd44;\n border-left: 3px solid #58a6ff;\n }\n\n .card.alert {\n border-color: #f0883e44;\n border-left: 3px solid #f0883e;\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n margin-bottom: 10px;\n }\n\n .card-title {\n font-size: 14px;\n font-weight: 600;\n color: #f0f6fc;\n line-height: 1.3;\n flex: 1;\n margin-right: 8px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n }\n\n .card-title.expanded {\n -webkit-line-clamp: unset;\n overflow: visible;\n white-space: pre-wrap;\n }\n\n .expand-btn {\n background: none;\n border: none;\n color: #58a6ff;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n cursor: pointer;\n padding: 2px 0;\n opacity: 0.8;\n }\n\n .expand-btn:hover { opacity: 1; }\n\n .badge {\n font-size: 10px;\n font-weight: 600;\n padding: 3px 8px;\n border-radius: 12px;\n white-space: nowrap;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .badge.working {\n background: #0d419d;\n color: #58a6ff;\n animation: pulse 2s ease-in-out infinite;\n }\n\n .badge.waiting_permission {\n background: #5a1e02;\n color: #f0883e;\n }\n\n .badge.waiting_input {\n background: #3d2e00;\n color: #d29922;\n }\n\n .badge.idle {\n background: #21262d;\n color: #8b949e;\n }\n\n .badge.unknown {\n background: #21262d;\n color: #484f58;\n }\n\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.6; }\n }\n\n .pending-box {\n background: #1c1208;\n border: 1px solid #3d2e00;\n border-radius: 6px;\n padding: 8px 10px;\n margin-bottom: 10px;\n font-size: 12px;\n color: #d29922;\n }\n\n .pending-box .tool {\n color: #f0883e;\n font-weight: 600;\n }\n\n .pending-box .ago {\n color: #8b949e;\n float: right;\n }\n\n .action-row {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .action-btn {\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n font-weight: 600;\n padding: 4px 14px;\n border-radius: 6px;\n border: none;\n cursor: pointer;\n transition: opacity 0.2s, transform 0.1s;\n }\n\n .action-btn:hover { opacity: 0.85; }\n .action-btn:active { transform: scale(0.96); }\n\n .action-btn.allow {\n background: #238636;\n color: #ffffff;\n }\n\n .action-btn.deny {\n background: #da3633;\n color: #ffffff;\n }\n\n .action-btn.allow-all {\n background: #1f6feb;\n color: #ffffff;\n margin-left: auto;\n }\n\n .action-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .reply-input {\n flex: 1;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid #30363d;\n background: #0d1117;\n color: #c9d1d9;\n outline: none;\n }\n\n .reply-input:focus {\n border-color: #58a6ff;\n }\n\n .git-row {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 11px;\n margin-bottom: 6px;\n }\n\n .git-branch {\n color: #8b949e;\n }\n\n .git-branch::before {\n content: '\u2387 ';\n }\n\n .git-modified {\n color: #f85149;\n }\n\n .git-unpushed {\n color: #d29922;\n }\n\n .git-clean {\n color: #3fb950;\n }\n\n .needs-testing {\n display: inline-block;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n background: #490202;\n color: #f85149;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .flag {\n display: inline-block;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n letter-spacing: 0.3px;\n }\n\n .flag.ok {\n background: #0d2818;\n color: #3fb950;\n }\n\n .flag.pending {\n background: #3d2e00;\n color: #d29922;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 8px;\n font-size: 10px;\n color: #484f58;\n }\n\n .card-footer .spacer {\n margin-left: auto;\n }\n\n .notes-panel {\n background: #1a1e2e;\n border: 1px solid #2d333b;\n border-radius: 8px;\n padding: 12px;\n min-width: 0;\n }\n\n .notes-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 8px;\n font-size: 12px;\n font-weight: 600;\n color: #8b949e;\n }\n\n .notes-header .count {\n background: #2d333b;\n color: #c9d1d9;\n font-size: 10px;\n padding: 1px 7px;\n border-radius: 8px;\n }\n\n .note-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n border-radius: 4px;\n font-size: 12px;\n color: #c9d1d9;\n transition: background 0.15s;\n }\n\n .note-item:hover {\n background: #21262d;\n }\n\n .note-item .note-text {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: text;\n }\n\n .note-clock {\n font-size: 11px;\n cursor: default;\n flex-shrink: 0;\n }\n\n .note-thumb {\n width: 32px;\n height: 32px;\n object-fit: cover;\n border-radius: 4px;\n cursor: pointer;\n flex-shrink: 0;\n border: 1px solid #30363d;\n transition: transform 0.15s;\n }\n\n .note-thumb:hover {\n transform: scale(1.1);\n border-color: #58a6ff;\n }\n\n .note-lightbox {\n position: fixed;\n top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(0,0,0,0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n cursor: pointer;\n }\n\n .note-lightbox img {\n max-width: 90vw;\n max-height: 90vh;\n border-radius: 8px;\n box-shadow: 0 0 40px rgba(0,0,0,0.5);\n }\n\n .term-overlay {\n position: fixed;\n top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(0,0,0,0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n }\n\n .term-modal {\n width: min(1100px, 94vw);\n height: min(760px, 90vh);\n background: #0d1117;\n border: 1px solid #30363d;\n border-radius: 10px;\n display: flex;\n flex-direction: column;\n box-shadow: 0 0 40px rgba(0,0,0,0.6);\n overflow: hidden;\n }\n\n .term-head {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 14px;\n border-bottom: 1px solid #21262d;\n font-size: 13px;\n color: #c9d1d9;\n }\n\n .term-head .term-title { font-weight: 600; color: #f0f6fc; }\n .term-head .term-pane { color: #8b949e; }\n .term-head .term-close {\n margin-left: auto;\n background: none; border: none; color: #8b949e;\n font-size: 18px; cursor: pointer; padding: 0 4px;\n }\n .term-head .term-close:hover { color: #f85149; }\n\n .term-body { flex: 1; min-height: 0; padding: 8px; }\n .term-body .xterm { height: 100%; }\n\n .term-input-row {\n display: flex;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid #21262d;\n }\n .term-input-row input {\n flex: 1;\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 6px;\n color: #c9d1d9;\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px;\n padding: 6px 10px;\n }\n .term-input-row input:focus { outline: none; border-color: #58a6ff; }\n\n .term-btn {\n background: none; border: none; cursor: pointer;\n font-size: 13px; opacity: 0.35; transition: opacity 0.2s; padding: 2px 4px;\n }\n .term-btn:hover { opacity: 0.9; }\n\n .note-grip {\n cursor: grab;\n color: #484f58;\n font-size: 10px;\n user-select: none;\n }\n .note-item.dragging {\n opacity: 0.4;\n }\n .note-item.drag-over {\n border-top: 2px solid #58a6ff;\n margin-top: -2px;\n }\n .note-btn.move {\n background: none;\n color: #484f58;\n padding: 0 2px;\n font-size: 8px;\n min-width: 16px;\n }\n .note-btn.move:hover {\n color: #58a6ff;\n }\n .note-btn {\n font-family: 'JetBrains Mono', monospace;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n border: none;\n cursor: pointer;\n transition: opacity 0.2s;\n }\n\n .note-btn:hover { opacity: 0.85; }\n\n .note-btn.send {\n background: #238636;\n color: #fff;\n }\n\n .note-btn.delete {\n background: #21262d;\n color: #8b949e;\n }\n\n .note-btn.delete:hover {\n background: #da3633;\n color: #fff;\n }\n\n .note-add-row {\n display: flex;\n gap: 6px;\n margin-top: 8px;\n }\n\n .empty {\n text-align: center;\n padding: 60px 20px;\n color: #484f58;\n }\n\n .empty h2 {\n font-size: 18px;\n color: #8b949e;\n margin-bottom: 8px;\n }\n\n .empty p {\n font-size: 13px;\n }\n\n @media (max-width: 768px) {\n body { padding: 12px; }\n\n header { flex-direction: column; align-items: flex-start; gap: 8px; }\n\n .logo h1 { font-size: 18px; }\n\n .sessions {\n grid-template-columns: 1fr;\n gap: 10px;\n }\n\n .project-header {\n flex-wrap: wrap;\n }\n\n .project-path { display: none; }\n\n .card { padding: 12px; }\n\n .card-title { font-size: 13px; }\n\n .action-btn {\n padding: 8px 18px;\n font-size: 13px;\n }\n\n .reply-input {\n font-size: 13px;\n padding: 8px 10px;\n }\n\n .ci-row { flex-wrap: wrap; gap: 6px; }\n\n .pending-box { font-size: 11px; }\n\n .pending-box code { font-size: 9px; }\n }\n\n @media (max-width: 480px) {\n body { padding: 8px; }\n\n .logo h1 { font-size: 16px; }\n\n .badge { font-size: 9px; padding: 2px 6px; }\n\n .git-row { flex-wrap: wrap; gap: 6px; }\n\n .action-btn {\n padding: 10px 20px;\n font-size: 14px;\n }\n\n .action-btn.allow-all {\n width: 100%;\n text-align: center;\n }\n }\n\n .scanline {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n background: repeating-linear-gradient(\n 0deg,\n transparent,\n transparent 2px,\n rgba(0, 0, 0, 0.03) 2px,\n rgba(0, 0, 0, 0.03) 4px\n );\n z-index: 999;\n }\n </style>\n</head>\n<body>\n <div class=\"scanline\"></div>\n <header>\n <div class=\"logo\">\n <h1>claude-pager<span class=\"cursor\"></span></h1>\n </div>\n <div class=\"meta\">\n <button class=\"action-btn allow-all\" id=\"allowAllBtn\" style=\"display:none\" onclick=\"allowAll()\">Allow All</button>\n <span class=\"status-dot connected\" id=\"statusDot\"></span>\n <span id=\"lastUpdate\">connecting...</span>\n </div>\n </header>\n <main id=\"projects\"></main>\n\n <script>\n let data = null;\n const sentMessages = new Map(); // sessionId \u2192 { icon, text, at }\n\n function getPinnedOrder() {\n try { return JSON.parse(localStorage.getItem('dashboard-pin-order') || '[]'); }\n catch { return []; }\n }\n\n function savePinnedOrder(order) {\n localStorage.setItem('dashboard-pin-order', JSON.stringify(order));\n }\n\n function togglePin(name) {\n const order = getPinnedOrder();\n const idx = order.indexOf(name);\n if (idx >= 0) {\n order.splice(idx, 1);\n } else {\n order.push(name);\n }\n savePinnedOrder(order);\n if (data) render(data);\n }\n\n function sortProjects(projects) {\n const pinned = getPinnedOrder();\n const hasQuestion = (p) => p.sessions.some(s =>\n s.state === 'waiting_permission' || s.state === 'waiting_input'\n );\n return [...projects].sort((a, b) => {\n // Tier 1: projects with a session asking something float to the top.\n const aQ = hasQuestion(a);\n const bQ = hasQuestion(b);\n if (aQ !== bQ) return aQ ? -1 : 1;\n\n // Within the same tier, pinned projects keep their pinned order.\n const aPin = pinned.indexOf(a.name);\n const bPin = pinned.indexOf(b.name);\n const aIsPinned = aPin >= 0;\n const bIsPinned = bPin >= 0;\n if (aIsPinned && bIsPinned) return aPin - bPin;\n if (aIsPinned) return -1;\n if (bIsPinned) return 1;\n\n // Otherwise preserve backend's state-based ordering.\n return 0;\n });\n }\n\n function escapeHtml(s) {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\n }\n\n function timeAgo(epochMs) {\n if (!epochMs) return 'unknown';\n const s = Math.floor((Date.now() - epochMs) / 1000);\n if (s < 10) return 'just now';\n if (s < 60) return s + 's ago';\n if (s < 3600) return Math.floor(s / 60) + 'm ago';\n if (s < 86400) return Math.floor(s / 3600) + 'h ago';\n return Math.floor(s / 86400) + 'd ago';\n }\n\n function noteAgeColor(epochMs) {\n const m = (Date.now() - epochMs) / 60000;\n if (m < 10) return '#3fb950'; // green \u2014 fresh\n if (m < 60) return '#d29922'; // yellow \u2014 aging\n if (m < 360) return '#f0883e'; // orange \u2014 old\n return '#f85149'; // red \u2014 stale\n }\n\n function formatToolInput(input) {\n if (!input) return '';\n const hasDiff = input.includes('--- old') && input.includes('+++ new');\n if (hasDiff) {\n const lines = input.slice(0, 800).split('\\n');\n const html = lines.map(line => {\n const escaped = escapeHtml(line);\n if (line.startsWith('+++ new')) return '<span style=\"color:#3fb950;font-weight:600\">' + escaped + '</span>';\n if (line.startsWith('--- old')) return '<span style=\"color:#f85149;font-weight:600\">' + escaped + '</span>';\n if (line.startsWith('+')) return '<span style=\"color:#3fb950\">' + escaped + '</span>';\n if (line.startsWith('-')) return '<span style=\"color:#f85149\">' + escaped + '</span>';\n return escaped;\n }).join('\\n');\n return '<pre style=\"font-size:10px;color:#8b949e;word-break:break-all;white-space:pre-wrap;margin:4px 0;max-height:200px;overflow-y:auto\">' + html + '</pre>';\n }\n return '<pre style=\"font-size:10px;color:#8b949e;word-break:break-all;white-space:pre-wrap;margin:4px 0;max-height:200px;overflow-y:auto\">' + escapeHtml(input.slice(0, 3000)) + '</pre>';\n }\n\n function stateLabel(state) {\n const labels = {\n working: 'Working',\n waiting_permission: 'Permission',\n waiting_input: 'Waiting',\n idle: 'Idle',\n unknown: '?',\n };\n return labels[state] || state;\n }\n\n function renderSession(s) {\n const ageMs = Date.now() - s.lastActivity;\n const isStale = (s.state === 'idle' || s.state === 'unknown') && ageMs > 2 * 3600_000;\n const isAlert = s.state === 'waiting_permission' || s.state === 'waiting_input';\n const isActive = s.state === 'working';\n const cardClass = isStale ? 'stale' : isAlert ? 'alert' : isActive ? 'active' : '';\n\n let pending = '';\n if (s.pendingQuestion) {\n const q = s.pendingQuestion;\n const isPermission = q.type === 'permission_prompt';\n const contextInfo = q.context\n ? '<div style=\"font-size:11px;color:#c9d1d9;margin-bottom:6px;white-space:pre-wrap\">' + escapeHtml(q.context.slice(-300)) + '</div>'\n : '';\n const toolInfo = q.toolName\n ? contextInfo + '<span class=\"tool\">' + escapeHtml(q.toolName) + '</span>' +\n (q.toolInput ? '<br>' + formatToolInput(q.toolInput) : '')\n : '<div class=\"prompt-msg\" style=\"font-size:12px;color:#c9d1d9;white-space:pre-wrap;max-height:240px;overflow-y:auto\">' + escapeHtml(q.message) + '</div>';\n\n const actions = isPermission\n ? `<div class=\"action-row\">\n <button class=\"action-btn allow\" onclick=\"respondTo('${q.eventId}', 'allow', this)\">\u2713 Allow</button>\n <button class=\"action-btn deny\" onclick=\"respondTo('${q.eventId}', 'deny', this)\">\u2717 Deny</button>\n </div>`\n : `<div class=\"action-row\" style=\"align-items:center\">\n <input type=\"text\" class=\"reply-input\" id=\"reply-${q.eventId}\" placeholder=\"Type a reply...\" onkeydown=\"if(event.key==='Enter')respondTo('${q.eventId}',this.value,this)\">\n <button class=\"action-btn allow\" onclick=\"respondTo('${q.eventId}',document.getElementById('reply-${q.eventId}').value,this)\">Send</button>\n </div>`;\n\n pending = `\n <div class=\"pending-box\">\n <span class=\"ago\">${timeAgo(Date.now() - q.agoSeconds * 1000)}</span>\n ${toolInfo}\n ${actions}\n </div>\n `;\n }\n\n const hasGit = s.git.branch !== 'unknown';\n\n const gitParts = [];\n if (hasGit) {\n gitParts.push('<span class=\"git-branch\">' + escapeHtml(s.git.branch) + '</span>');\n gitParts.push(s.git.modifiedFiles > 0\n ? '<span class=\"git-modified\">' + s.git.modifiedFiles + ' mod</span>'\n : '<span class=\"git-clean\">clean</span>');\n if (s.git.unpushedCommits > 0) gitParts.push('<span class=\"git-unpushed\">' + s.git.unpushedCommits + ' unpush</span>');\n gitParts.push(s.committed\n ? '<span class=\"flag ok\">\u2713 commit</span>'\n : '<span class=\"flag pending\">\u25CB uncommit</span>');\n gitParts.push(s.pushed\n ? '<span class=\"flag ok\">\u2713 push</span>'\n : '<span class=\"flag pending\">\u25CB unpush</span>');\n }\n\n // Show reply input for idle/waiting sessions without a pending question\n const idleInput = (!s.pendingQuestion && (s.state === 'idle' || s.state === 'waiting_input' || s.state === 'unknown'))\n ? `<div class=\"action-row\" style=\"margin-top:6px\">\n <input type=\"text\" class=\"reply-input\" id=\"idle-${s.sessionId}\" placeholder=\"Send a message...\" onkeydown=\"if(event.key==='Enter')sendToSession('${s.sessionId}',this.value,this)\">\n <button class=\"action-btn allow\" onclick=\"sendToSession('${s.sessionId}',document.getElementById('idle-${s.sessionId}').value,this)\">Send</button>\n </div>`\n : '';\n\n const titleId = 'title-' + s.sessionId.slice(0, 8);\n const longTitle = s.title.length > 80;\n const expandBtn = longTitle ? `<button class=\"expand-btn\" onclick=\"document.getElementById('${titleId}').classList.toggle('expanded');this.textContent=this.textContent==='...'?'\u25B2':'...'\">...</button>` : '';\n\n return `\n <div class=\"card ${cardClass}\">\n <div class=\"card-header\">\n <span class=\"card-title\" id=\"${titleId}\">${escapeHtml(s.title)}</span>\n <span class=\"badge ${s.state}\">${stateLabel(s.state)}</span>\n ${(data && data.terminalEnabled && s.tmuxPane)\n ? '<button class=\"term-btn\" onclick=\"openTerminal(\\'' + s.sessionId + '\\',\\'' + escapeHtml(s.tmuxPane) + '\\')\" title=\"Open terminal\">\uD83D\uDCDF</button>'\n : ''}\n <button class=\"dismiss-btn\" onclick=\"dismissSession('${s.sessionId}')\" title=\"Dismiss session\">\uD83D\uDDD1</button>\n </div>\n ${expandBtn}\n ${(() => {\n const sent = sentMessages.get(s.sessionId);\n if (sent && Date.now() - sent.at < 300_000) {\n const label = sent.icon === '\u25B6' ? 'Working on' : 'Replied';\n return '<div style=\"background:#0d2818;border:1px solid #238636;border-radius:6px;padding:6px 10px;margin:8px 0;font-size:12px;color:#c9d1d9;white-space:pre-wrap\">'\n + '<span style=\"color:#3fb950;font-weight:600\">' + label + ' :</span> '\n + escapeHtml(sent.text.length > 150 ? sent.text.slice(0, 150) + '...' : sent.text) + '</div>';\n }\n return '';\n })()}\n ${pending}\n ${s.lastAssistantText\n ? '<div class=\"prompt-msg\" style=\"font-size:12px;color:#c9d1d9;white-space:pre-wrap;max-height:240px;overflow-y:auto;background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:8px 10px;margin:8px 0\">' + escapeHtml(s.lastAssistantText) + '</div>'\n : ''}\n ${idleInput}\n <div class=\"card-footer\">\n ${gitParts.join(' ')}\n <span class=\"spacer\"></span>\n <span>pane ${escapeHtml(s.tmuxPane)}</span>\n <span>${timeAgo(s.lastActivity)}</span>\n </div>\n </div>\n `;\n }\n\n function renderPipeline(label, pipeline) {\n if (!pipeline) return '';\n const s = pipeline.status;\n const dot = '<span class=\"ci-dot ' + s + '\"></span>';\n const text = label + ': ' + s;\n if (pipeline.url) {\n return '<a class=\"ci-badge ' + s + '\" href=\"' + escapeHtml(pipeline.url) + '\" target=\"_blank\">' + dot + ' ' + text + '</a>';\n }\n return '<span class=\"ci-badge ' + s + '\">' + dot + ' ' + text + '</span>';\n }\n\n function renderCI(ci) {\n if (!ci) return '';\n const main = renderPipeline('main', ci.main);\n const staging = renderPipeline('staging', ci.staging);\n return main + staging;\n }\n\n function renderNotes(project, notes) {\n if (!notes || notes.length === 0) {\n return `\n <div class=\"notes-panel\">\n <div class=\"notes-header\">Notes</div>\n <div class=\"note-add-row\">\n <input type=\"text\" class=\"reply-input\" id=\"note-add-${escapeHtml(project)}\" placeholder=\"Add a note...\" onkeydown=\"if(event.key==='Enter')addNote('${escapeHtml(project)}',this.value,this)\" onpaste=\"handleNotePaste(event,'${escapeHtml(project)}')\">\n <button class=\"note-btn send\" onclick=\"var i=document.getElementById('note-add-${escapeHtml(project)}');addNote('${escapeHtml(project)}',i.value,i)\">+</button>\n </div>\n </div>\n `;\n }\n\n const items = notes.map((n, idx) => {\n const thumb = n.image\n ? '<img src=\"/api/v1/notes/images/' + escapeHtml(n.image) + '\" class=\"note-thumb\" onclick=\"openNoteImage(this.src)\" title=\"Click to enlarge\">'\n : '';\n return `\n <div class=\"note-item\" draggable=\"true\" data-note-id=\"${n.id}\" data-project=\"${escapeHtml(project)}\"\n ondragstart=\"onNoteDragStart(event)\" ondragover=\"onNoteDragOver(event)\" ondrop=\"onNoteDrop(event)\" ondragend=\"onNoteDragEnd(event)\">\n <span class=\"note-grip\" title=\"Drag to reorder\">\u283F</span>\n ${thumb}\n <span class=\"note-text\" title=\"${escapeHtml(n.text)}\" onclick=\"editNote(this,'${n.id}')\">${escapeHtml(n.text)}</span>\n <span class=\"note-clock\" title=\"${timeAgo(n.createdAt)}\" style=\"color:${noteAgeColor(n.createdAt)}\">\u23F1</span>\n ${idx > 0 ? '<button class=\"note-btn move\" onclick=\"moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',-1)\" title=\"Move up\">\u25B2</button>' : '<span class=\"note-btn move\" style=\"visibility:hidden\">\u25B2</span>'}\n ${idx < notes.length - 1 ? '<button class=\"note-btn move\" onclick=\"moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',1)\" title=\"Move down\">\u25BC</button>' : '<span class=\"note-btn move\" style=\"visibility:hidden\">\u25BC</span>'}\n <button class=\"note-btn send\" onclick=\"sendNote('${n.id}',this)\" title=\"Send to session\">\u25B6</button>\n <button class=\"note-btn delete\" onclick=\"deleteNote('${n.id}')\" title=\"Delete\">\u2715</button>\n </div>\n `}).join('');\n\n return `\n <div class=\"notes-panel\">\n <div class=\"notes-header\">\n <span>Notes</span>\n <span class=\"count\">${notes.length}</span>\n </div>\n ${items}\n <div class=\"note-add-row\">\n <input type=\"text\" class=\"reply-input\" id=\"note-add-${escapeHtml(project)}\" placeholder=\"Add a note...\" onkeydown=\"if(event.key==='Enter')addNote('${escapeHtml(project)}',this.value,this)\" onpaste=\"handleNotePaste(event,'${escapeHtml(project)}')\">\n <button class=\"note-btn send\" onclick=\"var i=document.getElementById('note-add-${escapeHtml(project)}');addNote('${escapeHtml(project)}',i.value,i)\">+</button>\n </div>\n </div>\n `;\n }\n\n function renderProject(p) {\n const isPinned = getPinnedOrder().includes(p.name);\n const anyNeedsTesting = p.sessions.some(s => s.needsTesting);\n const testBadge = anyNeedsTesting ? '<span class=\"needs-testing\">needs testing</span>' : '';\n const ciBadges = renderCI(p.ci);\n const infoRow = (ciBadges || testBadge) ? '<div class=\"ci-row\">' + ciBadges + testBadge + '</div>' : '';\n\n return `\n <div class=\"project\">\n <div class=\"project-header\">\n <button class=\"pin-btn ${isPinned ? 'pinned' : ''}\" onclick=\"togglePin('${escapeHtml(p.name)}')\" title=\"${isPinned ? 'Unpin' : 'Pin'}\">${isPinned ? '\uD83D\uDCCC' : '\uD83D\uDCCC'}</button>\n <h2>${escapeHtml(p.name)}</h2>\n <span class=\"project-count\">${p.sessions.length} session${p.sessions.length > 1 ? 's' : ''}</span>\n <span class=\"project-path\">${escapeHtml(p.path)}</span>\n </div>\n ${infoRow}\n <div class=\"sessions\">\n ${p.sessions.map(renderSession).join('')}\n ${renderNotes(p.name, p.notes)}\n </div>\n </div>\n `;\n }\n\n function countPending(data) {\n let count = 0;\n for (const p of data.projects) {\n for (const s of p.sessions) {\n if (s.pendingQuestion && s.pendingQuestion.type === 'permission_prompt') count++;\n }\n }\n return count;\n }\n\n function render(data) {\n const container = document.getElementById('projects');\n if (!data.projects || data.projects.length === 0) {\n container.innerHTML = `\n <div class=\"empty\">\n <h2>No active sessions</h2>\n <p>Start Claude Code in tmux and sessions will appear here.</p>\n </div>\n `;\n document.getElementById('allowAllBtn').style.display = 'none';\n return;\n }\n\n const pendingCount = countPending(data);\n const allowAllBtn = document.getElementById('allowAllBtn');\n if (pendingCount > 1) {\n allowAllBtn.style.display = 'inline-block';\n allowAllBtn.textContent = 'Allow All (' + pendingCount + ')';\n } else {\n allowAllBtn.style.display = 'none';\n }\n\n // Skip DOM update if user is focused on an input field\n const focused = document.activeElement;\n if (focused && focused.tagName === 'INPUT' && focused.classList.contains('reply-input')) return;\n\n // Preserve input values and cursor position across re-renders\n const savedInputs = new Map();\n const focusedId = focused?.id;\n const cursorPos = focused?.selectionStart;\n document.querySelectorAll('.reply-input').forEach(inp => {\n if (inp.id && inp.value.trim()) savedInputs.set(inp.id, inp.value);\n });\n const expandedTitles = new Set();\n document.querySelectorAll('.card-title.expanded').forEach(el => expandedTitles.add(el.id));\n\n container.innerHTML = sortProjects(data.projects).map(renderProject).join('');\n\n // Autoscroll prompt messages to bottom \u2014 Claude puts the actual question\n // at the end of an idle_prompt, so reveal that part on render.\n document.querySelectorAll('.prompt-msg').forEach(el => { el.scrollTop = el.scrollHeight; });\n\n // Restore expanded titles\n expandedTitles.forEach(id => {\n const el = document.getElementById(id);\n if (el) {\n el.classList.add('expanded');\n const btn = el.parentElement?.querySelector('.expand-btn');\n if (btn) btn.textContent = '\u25B2';\n }\n });\n\n // Restore input values and focus\n savedInputs.forEach((val, id) => {\n const inp = document.getElementById(id);\n if (inp) inp.value = val;\n });\n if (focusedId) {\n const el = document.getElementById(focusedId);\n if (el) { el.focus(); if (cursorPos != null) el.selectionStart = el.selectionEnd = cursorPos; }\n }\n }\n\n function showSentBanner(card, icon, text, sessionId) {\n if (!card) return;\n // Track for persistent rendering across re-renders\n const sid = sessionId || card.querySelector('.card-title')?.id?.replace('title-', '');\n if (sid) {\n // Find full sessionId from data\n const fullId = data?.projects?.flatMap(p => p.sessions).find(s => s.sessionId.startsWith(sid))?.sessionId || sid;\n sentMessages.set(fullId, { icon, text, at: Date.now() });\n }\n }\n\n async function respondTo(eventId, response, btn) {\n if (btn) btn.disabled = true;\n const card = btn?.closest('.card');\n try {\n const res = await fetch('/api/v1/respond-to', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ eventId, response }),\n });\n if (res.ok) {\n const label = response === 'allow' ? '\u2713 Allowed' : response === 'deny' ? '\u2717 Denied' : response;\n showSentBanner(card, '\u21A9', label);\n fetchDashboard();\n } else {\n const err = await res.json();\n console.error('respond-to failed:', err);\n }\n } catch (e) {\n console.error('respond-to error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n async function dismissSession(sessionId) {\n try {\n await fetch('/api/v1/dismiss-session', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ sessionId }),\n });\n fetchDashboard();\n } catch (e) {\n console.error('dismiss error:', e);\n }\n }\n\n async function sendToSession(sessionId, text, btn) {\n if (!text || !text.trim()) return;\n if (btn) btn.disabled = true;\n const card = btn?.closest('.card');\n const input = card?.querySelector('input');\n if (input) { input.value = ''; input.blur(); }\n try {\n const res = await fetch('/api/v1/send-to', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ sessionId, text: text.trim() }),\n });\n if (res.ok) {\n showSentBanner(card, '\u25B6', text.trim());\n fetchDashboard();\n } else {\n const err = await res.json();\n console.error('send-to failed:', err);\n }\n } catch (e) {\n console.error('send-to error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n let addingNote = false;\n async function addNote(project, text, input) {\n if (!text || !text.trim() || addingNote) return;\n addingNote = true;\n if (input) { input.value = ''; input.blur(); }\n try {\n await fetch('/api/v1/notes', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, text: text.trim(), source: 'dashboard' }),\n });\n fetchDashboard();\n } catch (e) {\n console.error('add-note error:', e);\n }\n addingNote = false;\n }\n\n async function handleNotePaste(event, project) {\n const items = event.clipboardData?.items;\n if (!items) return;\n for (const item of items) {\n if (item.type.startsWith('image/')) {\n event.preventDefault();\n const blob = item.getAsFile();\n if (!blob) return;\n const reader = new FileReader();\n reader.onload = async () => {\n const base64 = reader.result.split(',')[1];\n const text = event.target.value?.trim() || '';\n try {\n await fetch('/api/v1/notes/with-image', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, text: text || '(image)', imageBase64: base64, source: 'dashboard' }),\n });\n event.target.value = '';\n fetchDashboard();\n } catch (e) {\n console.error('paste-image error:', e);\n }\n };\n reader.readAsDataURL(blob);\n return;\n }\n }\n }\n\n function openNoteImage(src) {\n const lb = document.createElement('div');\n lb.className = 'note-lightbox';\n const img = document.createElement('img');\n img.src = src;\n lb.appendChild(img);\n lb.onclick = () => lb.remove();\n document.body.appendChild(lb);\n }\n\n function editNote(span, noteId) {\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'reply-input';\n input.value = span.textContent;\n input.style.cssText = 'flex:1;font-size:12px;';\n span.replaceWith(input);\n input.focus();\n input.select();\n async function save() {\n const text = input.value.trim();\n if (text && text !== span.textContent) {\n await fetch('/api/v1/notes/' + noteId, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ text }),\n });\n span.textContent = text;\n span.title = text;\n }\n input.replaceWith(span);\n }\n input.onblur = save;\n input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } if (e.key === 'Escape') input.replaceWith(span); };\n }\n\n async function deleteNote(noteId) {\n try {\n await fetch('/api/v1/notes/' + noteId, { method: 'DELETE' });\n fetchDashboard();\n } catch (e) {\n console.error('delete-note error:', e);\n }\n }\n\n async function sendNote(noteId, btn) {\n if (btn) btn.disabled = true;\n const noteText = btn?.closest('.note-item')?.querySelector('.note-text')?.textContent || '';\n try {\n const res = await fetch('/api/v1/notes/' + noteId + '/send', { method: 'POST' });\n const result = await res.json();\n if (!res.ok) {\n showToast(btn, result.error || 'Failed to send', true);\n } else {\n if (result.sessionId) {\n const card = document.querySelector('#title-' + result.sessionId.slice(0, 8))?.closest('.card');\n showSentBanner(card, '\u25B6', noteText);\n }\n showToast(btn, 'Sent', false);\n }\n fetchDashboard();\n } catch (e) {\n console.error('send-note error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n function getProjectNoteIds(project) {\n return Array.from(document.querySelectorAll('.note-item[data-project=\"' + project + '\"]'))\n .map(el => el.dataset.noteId);\n }\n\n async function saveNoteOrder(project) {\n const orderedIds = getProjectNoteIds(project);\n try {\n await fetch('/api/v1/notes/reorder', {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, orderedIds }),\n });\n } catch (e) {\n console.error('reorder error:', e);\n }\n }\n\n function moveNote(project, idx, direction) {\n const items = document.querySelectorAll('.note-item[data-project=\"' + project + '\"]');\n const target = idx + direction;\n if (target < 0 || target >= items.length) return;\n const parent = items[0].parentNode;\n if (direction === -1) parent.insertBefore(items[idx], items[target]);\n else parent.insertBefore(items[target], items[idx]);\n saveNoteOrder(project);\n }\n\n let draggedNote = null;\n function onNoteDragStart(e) {\n draggedNote = e.currentTarget;\n draggedNote.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n }\n function onNoteDragOver(e) {\n e.preventDefault();\n const item = e.currentTarget;\n if (item !== draggedNote) item.classList.add('drag-over');\n }\n function onNoteDrop(e) {\n e.preventDefault();\n const target = e.currentTarget;\n target.classList.remove('drag-over');\n if (!draggedNote || target === draggedNote) return;\n const parent = target.parentNode;\n const items = Array.from(parent.querySelectorAll('.note-item'));\n const fromIdx = items.indexOf(draggedNote);\n const toIdx = items.indexOf(target);\n if (fromIdx < toIdx) parent.insertBefore(draggedNote, target.nextSibling);\n else parent.insertBefore(draggedNote, target);\n saveNoteOrder(draggedNote.dataset.project);\n }\n function onNoteDragEnd(e) {\n document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));\n if (draggedNote) draggedNote.classList.remove('dragging');\n draggedNote = null;\n }\n\n function showToast(anchor, text, isError) {\n const toast = document.createElement('span');\n toast.textContent = (isError ? '\u2717 ' : '\u2713 ') + text;\n toast.style.cssText = 'position:absolute;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600;z-index:10;pointer-events:none;'\n + (isError ? 'background:#490202;color:#f85149;' : 'background:#0d2818;color:#3fb950;');\n if (anchor) {\n const rect = anchor.getBoundingClientRect();\n toast.style.left = rect.left + 'px';\n toast.style.top = (rect.top - 24) + 'px';\n } else {\n toast.style.right = '24px';\n toast.style.top = '70px';\n }\n document.body.appendChild(toast);\n setTimeout(() => toast.remove(), 2000);\n }\n\n async function allowAll() {\n if (!data) return;\n const pending = [];\n for (const p of data.projects) {\n for (const s of p.sessions) {\n if (s.pendingQuestion && s.pendingQuestion.type === 'permission_prompt') {\n pending.push(s.pendingQuestion);\n }\n }\n }\n for (const q of pending) {\n await respondTo(q.eventId, 'allow', null);\n }\n fetchDashboard();\n }\n\n // --- Terminal view ---\n let termState = null; // { overlay, term, sessionId, pollTimer }\n\n async function openTerminal(sessionId, pane) {\n if (termState) closeTerminal();\n\n const overlay = document.createElement('div');\n overlay.className = 'term-overlay';\n overlay.innerHTML =\n '<div class=\"term-modal\">'\n + '<div class=\"term-head\">'\n + '<span class=\"term-title\">Terminal</span>'\n + '<span class=\"term-pane\">pane ' + pane + '</span>'\n + '<button class=\"term-close\" title=\"Close\">\u2715</button>'\n + '</div>'\n + '<div class=\"term-body\"><div class=\"term-xterm\"></div></div>'\n + '<div class=\"term-input-row\">'\n + '<input type=\"text\" class=\"term-send\" placeholder=\"Type and press Enter to send to the session...\">'\n + '</div>'\n + '</div>';\n document.body.appendChild(overlay);\n\n const term = new Terminal({\n fontFamily: 'JetBrains Mono, monospace',\n fontSize: 12,\n convertEol: true,\n disableStdin: true,\n theme: { background: '#0d1117', foreground: '#c9d1d9' },\n scrollback: 5000,\n });\n term.open(overlay.querySelector('.term-xterm'));\n\n termState = { overlay, term, sessionId, pollTimer: null };\n\n // Close on \u2715, on click outside the modal, or Escape.\n overlay.querySelector('.term-close').onclick = closeTerminal;\n overlay.onclick = (e) => { if (e.target === overlay) closeTerminal(); };\n document.addEventListener('keydown', onTermEsc);\n\n const sendInput = overlay.querySelector('.term-send');\n sendInput.onkeydown = (e) => {\n if (e.key === 'Enter') {\n sendTerminalKeys(sessionId, sendInput.value);\n sendInput.value = '';\n }\n };\n sendInput.focus();\n\n await refreshTerminal();\n termState.pollTimer = setInterval(refreshTerminal, 2000);\n }\n\n function onTermEsc(e) { if (e.key === 'Escape') closeTerminal(); }\n\n function closeTerminal() {\n if (!termState) return;\n clearInterval(termState.pollTimer);\n document.removeEventListener('keydown', onTermEsc);\n termState.term.dispose();\n termState.overlay.remove();\n termState = null;\n }\n\n async function refreshTerminal() {\n if (!termState) return;\n try {\n const res = await fetch('/api/v1/session/' + termState.sessionId + '/terminal');\n if (!res.ok) return;\n const data = await res.json();\n // Snapshot rendering: reset then write the captured buffer (with ANSI).\n termState.term.reset();\n termState.term.write(data.content || '');\n } catch (e) {\n console.error('terminal refresh error:', e);\n }\n }\n\n async function sendTerminalKeys(sessionId, keys) {\n if (!keys) return;\n try {\n await fetch('/api/v1/session/' + sessionId + '/keys', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ keys, enter: true }),\n });\n setTimeout(refreshTerminal, 150);\n } catch (e) {\n console.error('terminal send error:', e);\n }\n }\n\n async function fetchDashboard() {\n try {\n const res = await fetch('/api/v1/dashboard');\n data = await res.json();\n render(data);\n document.getElementById('statusDot').className = 'status-dot connected';\n document.getElementById('lastUpdate').textContent = 'updated ' + timeAgo(data.updatedAt);\n } catch {\n document.getElementById('statusDot').className = 'status-dot disconnected';\n document.getElementById('lastUpdate').textContent = 'disconnected';\n }\n }\n\n fetchDashboard();\n\n // SSE for instant push \u2014 polling as fallback\n let fallbackTimer = setInterval(fetchDashboard, 10000);\n function connectSSE() {\n const es = new EventSource('/api/v1/sse');\n es.addEventListener('refresh', () => fetchDashboard());\n es.onopen = () => {\n clearInterval(fallbackTimer);\n fallbackTimer = setInterval(fetchDashboard, 10000);\n };\n es.onerror = () => {\n es.close();\n clearInterval(fallbackTimer);\n fallbackTimer = setInterval(fetchDashboard, 2000);\n setTimeout(connectSSE, 3000);\n };\n }\n connectSSE();\n </script>\n</body>\n</html>";
|
|
1
|
+
export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>claude-pager dashboard</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css\">\n <script src=\"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js\"></script>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n background: #0d1117;\n color: #c9d1d9;\n font-family: 'JetBrains Mono', monospace;\n min-height: 100vh;\n padding: 24px;\n }\n\n header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 32px;\n padding-bottom: 16px;\n border-bottom: 1px solid #21262d;\n }\n\n .logo {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .logo h1 {\n font-size: 22px;\n font-weight: 700;\n color: #f0f6fc;\n }\n\n .cursor {\n display: inline-block;\n width: 10px;\n height: 20px;\n background: #58a6ff;\n animation: blink 1s step-end infinite;\n vertical-align: middle;\n margin-left: 4px;\n }\n\n @keyframes blink {\n 50% { opacity: 0; }\n }\n\n .status-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n }\n\n .status-dot.connected { background: #3fb950; box-shadow: 0 0 6px #3fb950; }\n .status-dot.disconnected { background: #f85149; box-shadow: 0 0 6px #f85149; }\n\n .meta {\n font-size: 12px;\n color: #484f58;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .project {\n margin-bottom: 28px;\n }\n\n .project-header {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 12px;\n }\n\n .project-header h2 {\n font-size: 16px;\n font-weight: 600;\n color: #58a6ff;\n }\n\n .project-count {\n background: #21262d;\n color: #8b949e;\n font-size: 11px;\n padding: 2px 8px;\n border-radius: 10px;\n }\n\n .project-path {\n font-size: 11px;\n color: #484f58;\n margin-left: auto;\n }\n\n .pin-btn {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 14px;\n opacity: 0.3;\n transition: opacity 0.2s;\n padding: 2px 4px;\n }\n\n .pin-btn:hover { opacity: 0.7; }\n .pin-btn.pinned { opacity: 1; }\n\n .dismiss-btn {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 12px;\n opacity: 0.25;\n transition: opacity 0.2s;\n padding: 2px 4px;\n }\n\n .dismiss-btn:hover { opacity: 0.8; color: #f85149; }\n\n .sound-btn {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 16px;\n opacity: 0.7;\n transition: opacity 0.2s, transform 0.1s;\n padding: 2px 6px;\n }\n .sound-btn:hover { opacity: 1; }\n .sound-btn:active { transform: scale(0.92); }\n .sound-btn.muted { opacity: 0.35; }\n\n .ci-row {\n display: flex;\n gap: 12px;\n margin-bottom: 12px;\n font-size: 11px;\n }\n\n .ci-badge {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 3px 10px;\n border-radius: 12px;\n font-weight: 600;\n text-decoration: none;\n transition: opacity 0.2s;\n }\n\n .ci-badge:hover { opacity: 0.8; }\n\n .ci-badge.success { background: #0d2818; color: #3fb950; }\n .ci-badge.failed { background: #490202; color: #f85149; }\n .ci-badge.running { background: #0d419d; color: #58a6ff; animation: pulse 2s ease-in-out infinite; }\n .ci-badge.pending { background: #3d2e00; color: #d29922; }\n .ci-badge.canceled { background: #21262d; color: #8b949e; }\n .ci-badge.unknown { background: #21262d; color: #484f58; }\n\n .ci-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n display: inline-block;\n }\n\n .ci-dot.success { background: #3fb950; }\n .ci-dot.failed { background: #f85149; }\n .ci-dot.running { background: #58a6ff; }\n .ci-dot.pending { background: #d29922; }\n .ci-dot.canceled { background: #8b949e; }\n .ci-dot.unknown { background: #484f58; }\n\n .sessions {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));\n gap: 12px;\n }\n\n .card {\n background: #161b22;\n border: 1px solid #21262d;\n border-radius: 8px;\n padding: 16px;\n transition: border-color 0.2s, box-shadow 0.2s, opacity 0.3s;\n }\n\n .card:hover {\n border-color: #388bfd44;\n box-shadow: 0 0 12px #388bfd22;\n }\n\n .card.stale {\n opacity: 0.45;\n border-style: dashed;\n }\n\n .card.stale:hover {\n opacity: 0.8;\n }\n\n .card.active {\n border-color: #388bfd44;\n border-left: 3px solid #58a6ff;\n }\n\n .card.alert {\n border-color: #f0883e44;\n border-left: 3px solid #f0883e;\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n margin-bottom: 10px;\n }\n\n .card-title {\n font-size: 14px;\n font-weight: 600;\n color: #f0f6fc;\n line-height: 1.3;\n flex: 1;\n margin-right: 8px;\n overflow: hidden;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n }\n\n .card-title.expanded {\n -webkit-line-clamp: unset;\n overflow: visible;\n white-space: pre-wrap;\n }\n\n .expand-btn {\n background: none;\n border: none;\n color: #58a6ff;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n cursor: pointer;\n padding: 2px 0;\n opacity: 0.8;\n }\n\n .expand-btn:hover { opacity: 1; }\n\n .badge {\n font-size: 10px;\n font-weight: 600;\n padding: 3px 8px;\n border-radius: 12px;\n white-space: nowrap;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .badge.working {\n background: #0d419d;\n color: #58a6ff;\n animation: pulse 2s ease-in-out infinite;\n }\n\n .badge.waiting_permission {\n background: #5a1e02;\n color: #f0883e;\n }\n\n .badge.waiting_input {\n background: #3d2e00;\n color: #d29922;\n }\n\n .badge.idle {\n background: #21262d;\n color: #8b949e;\n }\n\n .badge.unknown {\n background: #21262d;\n color: #484f58;\n }\n\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.6; }\n }\n\n .pending-box {\n background: #1c1208;\n border: 1px solid #3d2e00;\n border-radius: 6px;\n padding: 8px 10px;\n margin-bottom: 10px;\n font-size: 12px;\n color: #d29922;\n }\n\n .pending-box .tool {\n color: #f0883e;\n font-weight: 600;\n }\n\n .pending-box .ago {\n color: #8b949e;\n float: right;\n }\n\n .aq-warning {\n background: #2a1810;\n border: 1px solid #5a2a02;\n border-radius: 4px;\n color: #f0883e;\n font-size: 11px;\n padding: 6px 8px;\n margin: 6px 0 8px;\n }\n\n .aq-list { font-size: 12px; color: #c9d1d9; max-height: 260px; overflow-y: auto; }\n .aq-question { margin: 6px 0; padding: 6px 8px; background: #0d1117; border-radius: 4px; }\n .aq-header { color: #58a6ff; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 3px; }\n .aq-q { color: #f0f6fc; font-weight: 600; margin-bottom: 4px; }\n .aq-options { list-style: none; padding: 0; margin: 0; }\n .aq-options li { padding: 2px 0; }\n .aq-opt-label { color: #c9d1d9; font-weight: 600; }\n .aq-opt-desc { color: #8b949e; }\n\n .action-btn.aq-allow {\n background: #0d419d;\n color: #cbe2ff;\n }\n .action-btn.aq-allow:hover { background: #1158c7; }\n\n .action-row {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n }\n\n .action-btn {\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n font-weight: 600;\n padding: 4px 14px;\n border-radius: 6px;\n border: none;\n cursor: pointer;\n transition: opacity 0.2s, transform 0.1s;\n }\n\n .action-btn:hover { opacity: 0.85; }\n .action-btn:active { transform: scale(0.96); }\n\n .action-btn.allow {\n background: #238636;\n color: #ffffff;\n }\n\n .action-btn.deny {\n background: #da3633;\n color: #ffffff;\n }\n\n .action-btn.allow-all {\n background: #1f6feb;\n color: #ffffff;\n margin-left: auto;\n }\n\n .action-btn:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .reply-input {\n flex: 1;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid #30363d;\n background: #0d1117;\n color: #c9d1d9;\n outline: none;\n }\n\n .reply-input:focus {\n border-color: #58a6ff;\n }\n\n .git-row {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 11px;\n margin-bottom: 6px;\n }\n\n .git-branch {\n color: #8b949e;\n }\n\n .git-branch::before {\n content: '\u2387 ';\n }\n\n .git-modified {\n color: #f85149;\n }\n\n .git-unpushed {\n color: #d29922;\n }\n\n .git-clean {\n color: #3fb950;\n }\n\n .needs-testing {\n display: inline-block;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n background: #490202;\n color: #f85149;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n }\n\n .flag {\n display: inline-block;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 10px;\n letter-spacing: 0.3px;\n }\n\n .flag.ok {\n background: #0d2818;\n color: #3fb950;\n }\n\n .flag.pending {\n background: #3d2e00;\n color: #d29922;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 8px;\n font-size: 10px;\n color: #484f58;\n }\n\n .card-footer .spacer {\n margin-left: auto;\n }\n\n .notes-panel {\n background: #1a1e2e;\n border: 1px solid #2d333b;\n border-radius: 8px;\n padding: 12px;\n min-width: 0;\n }\n\n .notes-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 8px;\n font-size: 12px;\n font-weight: 600;\n color: #8b949e;\n }\n\n .notes-header .count {\n background: #2d333b;\n color: #c9d1d9;\n font-size: 10px;\n padding: 1px 7px;\n border-radius: 8px;\n }\n\n .note-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n border-radius: 4px;\n font-size: 12px;\n color: #c9d1d9;\n transition: background 0.15s;\n }\n\n .note-item:hover {\n background: #21262d;\n }\n\n .note-item .note-text {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: text;\n }\n\n .note-clock {\n font-size: 11px;\n cursor: default;\n flex-shrink: 0;\n }\n\n .note-thumb {\n width: 32px;\n height: 32px;\n object-fit: cover;\n border-radius: 4px;\n cursor: pointer;\n flex-shrink: 0;\n border: 1px solid #30363d;\n transition: transform 0.15s;\n }\n\n .note-thumb:hover {\n transform: scale(1.1);\n border-color: #58a6ff;\n }\n\n .note-lightbox {\n position: fixed;\n top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(0,0,0,0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n cursor: pointer;\n }\n\n .note-lightbox img {\n max-width: 90vw;\n max-height: 90vh;\n border-radius: 8px;\n box-shadow: 0 0 40px rgba(0,0,0,0.5);\n }\n\n .term-overlay {\n position: fixed;\n top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(0,0,0,0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1000;\n }\n\n .term-modal {\n width: min(1100px, 94vw);\n height: min(760px, 90vh);\n background: #0d1117;\n border: 1px solid #30363d;\n border-radius: 10px;\n display: flex;\n flex-direction: column;\n box-shadow: 0 0 40px rgba(0,0,0,0.6);\n overflow: hidden;\n }\n\n .term-head {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 14px;\n border-bottom: 1px solid #21262d;\n font-size: 13px;\n color: #c9d1d9;\n }\n\n .term-head .term-title { font-weight: 600; color: #f0f6fc; }\n .term-head .term-pane { color: #8b949e; }\n .term-head .term-close {\n margin-left: auto;\n background: none; border: none; color: #8b949e;\n font-size: 18px; cursor: pointer; padding: 0 4px;\n }\n .term-head .term-close:hover { color: #f85149; }\n\n .term-body { flex: 1; min-height: 0; padding: 8px; }\n .term-body .xterm { height: 100%; }\n\n .term-keys-row {\n display: flex;\n gap: 6px;\n padding: 8px 14px;\n border-top: 1px solid #21262d;\n flex-wrap: wrap;\n }\n\n .term-key-btn {\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 4px;\n color: #c9d1d9;\n font-family: 'JetBrains Mono', monospace;\n font-size: 11px;\n padding: 3px 8px;\n cursor: pointer;\n min-width: 28px;\n transition: background 0.15s, border-color 0.15s;\n }\n\n .term-key-btn:hover { background: #21262d; border-color: #58a6ff; }\n .term-key-btn:active { background: #0d419d; }\n\n .term-input-row {\n display: flex;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid #21262d;\n }\n .term-input-row input {\n flex: 1;\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 6px;\n color: #c9d1d9;\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px;\n padding: 6px 10px;\n }\n .term-input-row input:focus { outline: none; border-color: #58a6ff; }\n\n .term-btn {\n background: none; border: none; cursor: pointer;\n font-size: 13px; opacity: 0.35; transition: opacity 0.2s; padding: 2px 4px;\n }\n .term-btn:hover { opacity: 0.9; }\n\n .note-grip {\n cursor: grab;\n color: #484f58;\n font-size: 10px;\n user-select: none;\n }\n .note-item.dragging {\n opacity: 0.4;\n }\n .note-item.drag-over {\n border-top: 2px solid #58a6ff;\n margin-top: -2px;\n }\n .note-btn.move {\n background: none;\n color: #484f58;\n padding: 0 2px;\n font-size: 8px;\n min-width: 16px;\n }\n .note-btn.move:hover {\n color: #58a6ff;\n }\n .note-btn {\n font-family: 'JetBrains Mono', monospace;\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n border: none;\n cursor: pointer;\n transition: opacity 0.2s;\n }\n\n .note-btn:hover { opacity: 0.85; }\n\n .note-btn.send {\n background: #238636;\n color: #fff;\n }\n\n .note-btn.delete {\n background: #21262d;\n color: #8b949e;\n }\n\n .note-btn.delete:hover {\n background: #da3633;\n color: #fff;\n }\n\n .note-add-row {\n display: flex;\n gap: 6px;\n margin-top: 8px;\n }\n\n .empty {\n text-align: center;\n padding: 60px 20px;\n color: #484f58;\n }\n\n .empty h2 {\n font-size: 18px;\n color: #8b949e;\n margin-bottom: 8px;\n }\n\n .empty p {\n font-size: 13px;\n }\n\n @media (max-width: 768px) {\n body { padding: 12px; }\n\n header { flex-direction: column; align-items: flex-start; gap: 8px; }\n\n .logo h1 { font-size: 18px; }\n\n .sessions {\n grid-template-columns: 1fr;\n gap: 10px;\n }\n\n .project-header {\n flex-wrap: wrap;\n }\n\n .project-path { display: none; }\n\n .card { padding: 12px; }\n\n .card-title { font-size: 13px; }\n\n .action-btn {\n padding: 8px 18px;\n font-size: 13px;\n }\n\n .reply-input {\n font-size: 13px;\n padding: 8px 10px;\n }\n\n .ci-row { flex-wrap: wrap; gap: 6px; }\n\n .pending-box { font-size: 11px; }\n\n .pending-box code { font-size: 9px; }\n }\n\n @media (max-width: 480px) {\n body { padding: 8px; }\n\n .logo h1 { font-size: 16px; }\n\n .badge { font-size: 9px; padding: 2px 6px; }\n\n .git-row { flex-wrap: wrap; gap: 6px; }\n\n .action-btn {\n padding: 10px 20px;\n font-size: 14px;\n }\n\n .action-btn.allow-all {\n width: 100%;\n text-align: center;\n }\n }\n\n .scanline {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n background: repeating-linear-gradient(\n 0deg,\n transparent,\n transparent 2px,\n rgba(0, 0, 0, 0.03) 2px,\n rgba(0, 0, 0, 0.03) 4px\n );\n z-index: 999;\n }\n </style>\n</head>\n<body>\n <div class=\"scanline\"></div>\n <header>\n <div class=\"logo\">\n <h1>claude-pager<span class=\"cursor\"></span></h1>\n </div>\n <div class=\"meta\">\n <button class=\"action-btn allow-all\" id=\"allowAllBtn\" style=\"display:none\" onclick=\"allowAll()\">Allow All</button>\n <button class=\"sound-btn\" id=\"soundBtn\" onclick=\"toggleSound()\" title=\"Toggle notification sound\">\uD83D\uDD14</button>\n <span class=\"status-dot connected\" id=\"statusDot\"></span>\n <span id=\"lastUpdate\">connecting...</span>\n </div>\n </header>\n <main id=\"projects\"></main>\n\n <script>\n let data = null;\n const sentMessages = new Map(); // sessionId \u2192 { icon, text, at }\n\n function getPinnedOrder() {\n try { return JSON.parse(localStorage.getItem('dashboard-pin-order') || '[]'); }\n catch { return []; }\n }\n\n function savePinnedOrder(order) {\n localStorage.setItem('dashboard-pin-order', JSON.stringify(order));\n }\n\n function togglePin(name) {\n const order = getPinnedOrder();\n const idx = order.indexOf(name);\n if (idx >= 0) {\n order.splice(idx, 1);\n } else {\n order.push(name);\n }\n savePinnedOrder(order);\n if (data) render(data);\n }\n\n function sortProjects(projects) {\n const pinned = getPinnedOrder();\n const hasQuestion = (p) => p.sessions.some(s =>\n s.state === 'waiting_permission' || s.state === 'waiting_input'\n );\n return [...projects].sort((a, b) => {\n // Tier 1: projects with a session asking something float to the top.\n const aQ = hasQuestion(a);\n const bQ = hasQuestion(b);\n if (aQ !== bQ) return aQ ? -1 : 1;\n\n // Within the same tier, pinned projects keep their pinned order.\n const aPin = pinned.indexOf(a.name);\n const bPin = pinned.indexOf(b.name);\n const aIsPinned = aPin >= 0;\n const bIsPinned = bPin >= 0;\n if (aIsPinned && bIsPinned) return aPin - bPin;\n if (aIsPinned) return -1;\n if (bIsPinned) return 1;\n\n // Otherwise preserve backend's state-based ordering.\n return 0;\n });\n }\n\n function escapeHtml(s) {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\n }\n\n function timeAgo(epochMs) {\n if (!epochMs) return 'unknown';\n const s = Math.floor((Date.now() - epochMs) / 1000);\n if (s < 10) return 'just now';\n if (s < 60) return s + 's ago';\n if (s < 3600) return Math.floor(s / 60) + 'm ago';\n if (s < 86400) return Math.floor(s / 3600) + 'h ago';\n return Math.floor(s / 86400) + 'd ago';\n }\n\n function noteAgeColor(epochMs) {\n const m = (Date.now() - epochMs) / 60000;\n if (m < 10) return '#3fb950'; // green \u2014 fresh\n if (m < 60) return '#d29922'; // yellow \u2014 aging\n if (m < 360) return '#f0883e'; // orange \u2014 old\n return '#f85149'; // red \u2014 stale\n }\n\n function formatToolInput(input) {\n if (!input) return '';\n const hasDiff = input.includes('--- old') && input.includes('+++ new');\n if (hasDiff) {\n const lines = input.slice(0, 800).split('\\n');\n const html = lines.map(line => {\n const escaped = escapeHtml(line);\n if (line.startsWith('+++ new')) return '<span style=\"color:#3fb950;font-weight:600\">' + escaped + '</span>';\n if (line.startsWith('--- old')) return '<span style=\"color:#f85149;font-weight:600\">' + escaped + '</span>';\n if (line.startsWith('+')) return '<span style=\"color:#3fb950\">' + escaped + '</span>';\n if (line.startsWith('-')) return '<span style=\"color:#f85149\">' + escaped + '</span>';\n return escaped;\n }).join('\\n');\n return '<pre style=\"font-size:10px;color:#8b949e;word-break:break-all;white-space:pre-wrap;margin:4px 0;max-height:200px;overflow-y:auto\">' + html + '</pre>';\n }\n return '<pre style=\"font-size:10px;color:#8b949e;word-break:break-all;white-space:pre-wrap;margin:4px 0;max-height:200px;overflow-y:auto\">' + escapeHtml(input.slice(0, 3000)) + '</pre>';\n }\n\n function parseAskUserQuestion(toolInput) {\n try {\n const obj = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;\n if (!obj || !Array.isArray(obj.questions)) return null;\n return obj.questions.map(q => ({\n header: q.header || '',\n question: q.question || '',\n options: Array.isArray(q.options)\n ? q.options.map(o => ({ label: o.label || '', description: o.description || '' }))\n : [],\n }));\n } catch { return null; }\n }\n\n function renderAskUser(questions) {\n return '<div class=\"aq-list\">' + questions.map((q, i) => {\n const head = q.header ? '<div class=\"aq-header\">' + escapeHtml(q.header) + '</div>' : '';\n const opts = q.options.map((o, j) =>\n '<li><span class=\"aq-opt-label\">' + escapeHtml(o.label) + '</span>'\n + (o.description ? '<span class=\"aq-opt-desc\"> \u2014 ' + escapeHtml(o.description.slice(0, 200)) + (o.description.length > 200 ? '\u2026' : '') + '</span>' : '')\n + '</li>'\n ).join('');\n return '<div class=\"aq-question\">'\n + head\n + '<div class=\"aq-q\">' + escapeHtml(q.question) + '</div>'\n + (opts ? '<ul class=\"aq-options\">' + opts + '</ul>' : '')\n + '</div>';\n }).join('') + '</div>';\n }\n\n async function openAskUserPicker(eventId, sessionId, pane, btn) {\n if (btn) btn.disabled = true;\n try {\n showToast(btn, 'Allow envoy\u00E9\u2026', false);\n // Allow the AskUserQuestion tool so Claude renders the picker in tmux.\n // We tell the user *exactly* this happened so the action is never\n // mistaken for picking an option.\n const res = await fetch('/api/v1/respond-to', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ eventId, response: 'allow' }),\n });\n if (!res.ok) {\n let msg = 'Allow failed';\n try { msg = (await res.json()).error || msg; } catch {}\n showToast(btn, msg, true);\n if (btn) btn.disabled = false;\n return;\n }\n // Give the picker a moment to render, then open the terminal modal so\n // the user can navigate with the arrow keys + Enter.\n setTimeout(() => openTerminal(sessionId, pane), 400);\n } catch (e) {\n console.error('openAskUserPicker error:', e);\n showToast(btn, 'Allow error: ' + e, true);\n }\n if (btn) btn.disabled = false;\n }\n\n function stateLabel(state) {\n const labels = {\n working: 'Working',\n waiting_permission: 'Permission',\n waiting_input: 'Waiting',\n idle: 'Idle',\n unknown: '?',\n };\n return labels[state] || state;\n }\n\n function renderSession(s) {\n const ageMs = Date.now() - s.lastActivity;\n const isStale = (s.state === 'idle' || s.state === 'unknown') && ageMs > 2 * 3600_000;\n const isAlert = s.state === 'waiting_permission' || s.state === 'waiting_input';\n const isActive = s.state === 'working';\n const cardClass = isStale ? 'stale' : isAlert ? 'alert' : isActive ? 'active' : '';\n\n let pending = '';\n if (s.pendingQuestion) {\n const q = s.pendingQuestion;\n const isPermission = q.type === 'permission_prompt';\n const contextInfo = q.context\n ? '<div style=\"font-size:11px;color:#c9d1d9;margin-bottom:6px;white-space:pre-wrap\">' + escapeHtml(q.context.slice(-300)) + '</div>'\n : '';\n\n // Special-case AskUserQuestion: this Claude Code tool opens an\n // interactive multi-question picker in the terminal. Allow only\n // *opens* the picker (does NOT pick an option) \u2014 clicking what looks\n // like an answer button would mislead the user. Render the questions\n // for context and route the user to the terminal modal to navigate.\n const askUser = (q.toolName === 'AskUserQuestion' && q.toolInput)\n ? parseAskUserQuestion(q.toolInput) : null;\n\n if (askUser && s.tmuxPane) {\n pending = `\n <div class=\"pending-box\">\n <span class=\"ago\">${timeAgo(Date.now() - q.agoSeconds * 1000)}</span>\n ${contextInfo}\n <div class=\"aq-warning\">\u26A0 Question interactive \u2014 Allow ouvre le picker mais ne r\u00E9pond pas. Utilise le terminal pour naviguer.</div>\n ${renderAskUser(askUser)}\n <div class=\"action-row\">\n <button class=\"action-btn aq-allow\" onclick=\"openAskUserPicker('${q.eventId}','${s.sessionId}','${s.tmuxPane}', this)\" title=\"Autorise le tool AskUserQuestion, puis ouvre la modal terminal pour naviguer le picker\">\uD83D\uDCDF Allow + ouvrir le terminal</button>\n <button class=\"action-btn deny\" onclick=\"respondTo('${q.eventId}', 'deny', this)\">\u2717 Deny</button>\n </div>\n </div>\n `;\n } else {\n const toolInfo = q.toolName\n ? contextInfo + '<span class=\"tool\">' + escapeHtml(q.toolName) + '</span>' +\n (q.toolInput ? '<br>' + formatToolInput(q.toolInput) : '')\n : '<div class=\"prompt-msg\" style=\"font-size:12px;color:#c9d1d9;white-space:pre-wrap;max-height:240px;overflow-y:auto\">' + escapeHtml(q.message) + '</div>';\n\n const actions = isPermission\n ? `<div class=\"action-row\">\n <button class=\"action-btn allow\" onclick=\"respondTo('${q.eventId}', 'allow', this)\">\u2713 Allow</button>\n <button class=\"action-btn deny\" onclick=\"respondTo('${q.eventId}', 'deny', this)\">\u2717 Deny</button>\n </div>`\n : `<div class=\"action-row\" style=\"align-items:center\">\n <input type=\"text\" class=\"reply-input\" id=\"reply-${q.eventId}\" placeholder=\"Type a reply...\" onkeydown=\"if(event.key==='Enter')respondTo('${q.eventId}',this.value,this)\">\n <button class=\"action-btn allow\" onclick=\"respondTo('${q.eventId}',document.getElementById('reply-${q.eventId}').value,this)\">Send</button>\n </div>`;\n\n pending = `\n <div class=\"pending-box\">\n <span class=\"ago\">${timeAgo(Date.now() - q.agoSeconds * 1000)}</span>\n ${toolInfo}\n ${actions}\n </div>\n `;\n }\n }\n\n const hasGit = s.git.branch !== 'unknown';\n\n const gitParts = [];\n if (hasGit) {\n gitParts.push('<span class=\"git-branch\">' + escapeHtml(s.git.branch) + '</span>');\n gitParts.push(s.git.modifiedFiles > 0\n ? '<span class=\"git-modified\">' + s.git.modifiedFiles + ' mod</span>'\n : '<span class=\"git-clean\">clean</span>');\n if (s.git.unpushedCommits > 0) gitParts.push('<span class=\"git-unpushed\">' + s.git.unpushedCommits + ' unpush</span>');\n gitParts.push(s.committed\n ? '<span class=\"flag ok\">\u2713 commit</span>'\n : '<span class=\"flag pending\">\u25CB uncommit</span>');\n gitParts.push(s.pushed\n ? '<span class=\"flag ok\">\u2713 push</span>'\n : '<span class=\"flag pending\">\u25CB unpush</span>');\n }\n\n // Show reply input for idle/waiting sessions without a pending question\n const idleInput = (!s.pendingQuestion && (s.state === 'idle' || s.state === 'waiting_input' || s.state === 'unknown'))\n ? `<div class=\"action-row\" style=\"margin-top:6px\">\n <input type=\"text\" class=\"reply-input\" id=\"idle-${s.sessionId}\" placeholder=\"Send a message...\" onkeydown=\"if(event.key==='Enter')sendToSession('${s.sessionId}',this.value,this)\">\n <button class=\"action-btn allow\" onclick=\"sendToSession('${s.sessionId}',document.getElementById('idle-${s.sessionId}').value,this)\">Send</button>\n </div>`\n : '';\n\n const titleId = 'title-' + s.sessionId.slice(0, 8);\n const longTitle = s.title.length > 80;\n const expandBtn = longTitle ? `<button class=\"expand-btn\" onclick=\"document.getElementById('${titleId}').classList.toggle('expanded');this.textContent=this.textContent==='...'?'\u25B2':'...'\">...</button>` : '';\n\n return `\n <div class=\"card ${cardClass}\">\n <div class=\"card-header\">\n <span class=\"card-title\" id=\"${titleId}\">${escapeHtml(s.title)}</span>\n <span class=\"badge ${s.state}\">${stateLabel(s.state)}</span>\n ${(data && data.terminalEnabled && s.tmuxPane)\n ? '<button class=\"term-btn\" onclick=\"openTerminal(\\'' + s.sessionId + '\\',\\'' + escapeHtml(s.tmuxPane) + '\\')\" title=\"Open terminal\">\uD83D\uDCDF</button>'\n : ''}\n <button class=\"dismiss-btn\" onclick=\"dismissSession('${s.sessionId}')\" title=\"Dismiss session\">\uD83D\uDDD1</button>\n </div>\n ${expandBtn}\n ${(() => {\n const sent = sentMessages.get(s.sessionId);\n if (sent && Date.now() - sent.at < 300_000) {\n const label = sent.icon === '\u25B6' ? 'Working on' : 'Replied';\n return '<div style=\"background:#0d2818;border:1px solid #238636;border-radius:6px;padding:6px 10px;margin:8px 0;font-size:12px;color:#c9d1d9;white-space:pre-wrap\">'\n + '<span style=\"color:#3fb950;font-weight:600\">' + label + ' :</span> '\n + escapeHtml(sent.text.length > 150 ? sent.text.slice(0, 150) + '...' : sent.text) + '</div>';\n }\n return '';\n })()}\n ${pending}\n ${s.lastAssistantText\n ? '<div class=\"prompt-msg\" style=\"font-size:12px;color:#c9d1d9;white-space:pre-wrap;max-height:240px;overflow-y:auto;background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:8px 10px;margin:8px 0\">' + escapeHtml(s.lastAssistantText) + '</div>'\n : ''}\n ${idleInput}\n <div class=\"card-footer\">\n ${gitParts.join(' ')}\n <span class=\"spacer\"></span>\n <span>pane ${escapeHtml(s.tmuxPane)}</span>\n <span>${timeAgo(s.lastActivity)}</span>\n </div>\n </div>\n `;\n }\n\n function renderPipeline(label, pipeline) {\n if (!pipeline) return '';\n const s = pipeline.status;\n const dot = '<span class=\"ci-dot ' + s + '\"></span>';\n const text = label + ': ' + s;\n if (pipeline.url) {\n return '<a class=\"ci-badge ' + s + '\" href=\"' + escapeHtml(pipeline.url) + '\" target=\"_blank\">' + dot + ' ' + text + '</a>';\n }\n return '<span class=\"ci-badge ' + s + '\">' + dot + ' ' + text + '</span>';\n }\n\n function renderCI(ci) {\n if (!ci) return '';\n const main = renderPipeline('main', ci.main);\n const staging = renderPipeline('staging', ci.staging);\n return main + staging;\n }\n\n function renderNotes(project, notes) {\n if (!notes || notes.length === 0) {\n return `\n <div class=\"notes-panel\">\n <div class=\"notes-header\">Notes</div>\n <div class=\"note-add-row\">\n <input type=\"text\" class=\"reply-input\" id=\"note-add-${escapeHtml(project)}\" placeholder=\"Add a note...\" onkeydown=\"if(event.key==='Enter')addNote('${escapeHtml(project)}',this.value,this)\" onpaste=\"handleNotePaste(event,'${escapeHtml(project)}')\">\n <button class=\"note-btn send\" onclick=\"var i=document.getElementById('note-add-${escapeHtml(project)}');addNote('${escapeHtml(project)}',i.value,i)\">+</button>\n </div>\n </div>\n `;\n }\n\n const items = notes.map((n, idx) => {\n const thumb = n.image\n ? '<img src=\"/api/v1/notes/images/' + escapeHtml(n.image) + '\" class=\"note-thumb\" onclick=\"openNoteImage(this.src)\" title=\"Click to enlarge\">'\n : '';\n return `\n <div class=\"note-item\" draggable=\"true\" data-note-id=\"${n.id}\" data-project=\"${escapeHtml(project)}\"\n ondragstart=\"onNoteDragStart(event)\" ondragover=\"onNoteDragOver(event)\" ondrop=\"onNoteDrop(event)\" ondragend=\"onNoteDragEnd(event)\">\n <span class=\"note-grip\" title=\"Drag to reorder\">\u283F</span>\n ${thumb}\n <span class=\"note-text\" title=\"${escapeHtml(n.text)}\" onclick=\"editNote(this,'${n.id}')\">${escapeHtml(n.text)}</span>\n <span class=\"note-clock\" title=\"${timeAgo(n.createdAt)}\" style=\"color:${noteAgeColor(n.createdAt)}\">\u23F1</span>\n ${idx > 0 ? '<button class=\"note-btn move\" onclick=\"moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',-1)\" title=\"Move up\">\u25B2</button>' : '<span class=\"note-btn move\" style=\"visibility:hidden\">\u25B2</span>'}\n ${idx < notes.length - 1 ? '<button class=\"note-btn move\" onclick=\"moveNote(\\'' + escapeHtml(project) + '\\',' + idx + ',1)\" title=\"Move down\">\u25BC</button>' : '<span class=\"note-btn move\" style=\"visibility:hidden\">\u25BC</span>'}\n <button class=\"note-btn send\" onclick=\"sendNote('${n.id}',this)\" title=\"Send to session\">\u25B6</button>\n <button class=\"note-btn delete\" onclick=\"deleteNote('${n.id}')\" title=\"Delete\">\u2715</button>\n </div>\n `}).join('');\n\n return `\n <div class=\"notes-panel\">\n <div class=\"notes-header\">\n <span>Notes</span>\n <span class=\"count\">${notes.length}</span>\n </div>\n ${items}\n <div class=\"note-add-row\">\n <input type=\"text\" class=\"reply-input\" id=\"note-add-${escapeHtml(project)}\" placeholder=\"Add a note...\" onkeydown=\"if(event.key==='Enter')addNote('${escapeHtml(project)}',this.value,this)\" onpaste=\"handleNotePaste(event,'${escapeHtml(project)}')\">\n <button class=\"note-btn send\" onclick=\"var i=document.getElementById('note-add-${escapeHtml(project)}');addNote('${escapeHtml(project)}',i.value,i)\">+</button>\n </div>\n </div>\n `;\n }\n\n function renderProject(p) {\n const isPinned = getPinnedOrder().includes(p.name);\n const anyNeedsTesting = p.sessions.some(s => s.needsTesting);\n const testBadge = anyNeedsTesting ? '<span class=\"needs-testing\">needs testing</span>' : '';\n const ciBadges = renderCI(p.ci);\n const infoRow = (ciBadges || testBadge) ? '<div class=\"ci-row\">' + ciBadges + testBadge + '</div>' : '';\n\n return `\n <div class=\"project\">\n <div class=\"project-header\">\n <button class=\"pin-btn ${isPinned ? 'pinned' : ''}\" onclick=\"togglePin('${escapeHtml(p.name)}')\" title=\"${isPinned ? 'Unpin' : 'Pin'}\">${isPinned ? '\uD83D\uDCCC' : '\uD83D\uDCCC'}</button>\n <h2>${escapeHtml(p.name)}</h2>\n <span class=\"project-count\">${p.sessions.length} session${p.sessions.length > 1 ? 's' : ''}</span>\n <span class=\"project-path\">${escapeHtml(p.path)}</span>\n </div>\n ${infoRow}\n <div class=\"sessions\">\n ${p.sessions.map(renderSession).join('')}\n ${renderNotes(p.name, p.notes)}\n </div>\n </div>\n `;\n }\n\n function countPending(data) {\n let count = 0;\n for (const p of data.projects) {\n for (const s of p.sessions) {\n if (s.pendingQuestion && s.pendingQuestion.type === 'permission_prompt') count++;\n }\n }\n return count;\n }\n\n function render(data) {\n detectNewPending(data);\n const container = document.getElementById('projects');\n if (!data.projects || data.projects.length === 0) {\n container.innerHTML = `\n <div class=\"empty\">\n <h2>No active sessions</h2>\n <p>Start Claude Code in tmux and sessions will appear here.</p>\n </div>\n `;\n document.getElementById('allowAllBtn').style.display = 'none';\n return;\n }\n\n const pendingCount = countPending(data);\n const allowAllBtn = document.getElementById('allowAllBtn');\n if (pendingCount > 1) {\n allowAllBtn.style.display = 'inline-block';\n allowAllBtn.textContent = 'Allow All (' + pendingCount + ')';\n } else {\n allowAllBtn.style.display = 'none';\n }\n\n // Skip DOM update if user is focused on an input field\n const focused = document.activeElement;\n if (focused && focused.tagName === 'INPUT' && focused.classList.contains('reply-input')) return;\n\n // Preserve input values and cursor position across re-renders\n const savedInputs = new Map();\n const focusedId = focused?.id;\n const cursorPos = focused?.selectionStart;\n document.querySelectorAll('.reply-input').forEach(inp => {\n if (inp.id && inp.value.trim()) savedInputs.set(inp.id, inp.value);\n });\n const expandedTitles = new Set();\n document.querySelectorAll('.card-title.expanded').forEach(el => expandedTitles.add(el.id));\n\n container.innerHTML = sortProjects(data.projects).map(renderProject).join('');\n\n // Autoscroll prompt messages to bottom \u2014 Claude puts the actual question\n // at the end of an idle_prompt, so reveal that part on render.\n document.querySelectorAll('.prompt-msg').forEach(el => { el.scrollTop = el.scrollHeight; });\n\n // Restore expanded titles\n expandedTitles.forEach(id => {\n const el = document.getElementById(id);\n if (el) {\n el.classList.add('expanded');\n const btn = el.parentElement?.querySelector('.expand-btn');\n if (btn) btn.textContent = '\u25B2';\n }\n });\n\n // Restore input values and focus\n savedInputs.forEach((val, id) => {\n const inp = document.getElementById(id);\n if (inp) inp.value = val;\n });\n if (focusedId) {\n const el = document.getElementById(focusedId);\n if (el) { el.focus(); if (cursorPos != null) el.selectionStart = el.selectionEnd = cursorPos; }\n }\n }\n\n function showSentBanner(card, icon, text, sessionId) {\n if (!card) return;\n // Track for persistent rendering across re-renders\n const sid = sessionId || card.querySelector('.card-title')?.id?.replace('title-', '');\n if (sid) {\n // Find full sessionId from data\n const fullId = data?.projects?.flatMap(p => p.sessions).find(s => s.sessionId.startsWith(sid))?.sessionId || sid;\n sentMessages.set(fullId, { icon, text, at: Date.now() });\n }\n }\n\n async function respondTo(eventId, response, btn) {\n if (btn) btn.disabled = true;\n const card = btn?.closest('.card');\n try {\n const res = await fetch('/api/v1/respond-to', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ eventId, response }),\n });\n if (res.ok) {\n const label = response === 'allow' ? '\u2713 Allowed' : response === 'deny' ? '\u2717 Denied' : response;\n showSentBanner(card, '\u21A9', label);\n fetchDashboard();\n } else {\n const err = await res.json();\n console.error('respond-to failed:', err);\n }\n } catch (e) {\n console.error('respond-to error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n async function dismissSession(sessionId) {\n try {\n await fetch('/api/v1/dismiss-session', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ sessionId }),\n });\n fetchDashboard();\n } catch (e) {\n console.error('dismiss error:', e);\n }\n }\n\n async function sendToSession(sessionId, text, btn) {\n if (!text || !text.trim()) return;\n if (btn) btn.disabled = true;\n const card = btn?.closest('.card');\n const input = card?.querySelector('input');\n if (input) { input.value = ''; input.blur(); }\n try {\n const res = await fetch('/api/v1/send-to', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ sessionId, text: text.trim() }),\n });\n if (res.ok) {\n showSentBanner(card, '\u25B6', text.trim());\n fetchDashboard();\n } else {\n const err = await res.json();\n console.error('send-to failed:', err);\n }\n } catch (e) {\n console.error('send-to error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n let addingNote = false;\n async function addNote(project, text, input) {\n if (!text || !text.trim() || addingNote) return;\n addingNote = true;\n if (input) { input.value = ''; input.blur(); }\n try {\n await fetch('/api/v1/notes', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, text: text.trim(), source: 'dashboard' }),\n });\n fetchDashboard();\n } catch (e) {\n console.error('add-note error:', e);\n }\n addingNote = false;\n }\n\n async function handleNotePaste(event, project) {\n const items = event.clipboardData?.items;\n if (!items) return;\n for (const item of items) {\n if (item.type.startsWith('image/')) {\n event.preventDefault();\n const blob = item.getAsFile();\n if (!blob) return;\n const reader = new FileReader();\n reader.onload = async () => {\n const base64 = reader.result.split(',')[1];\n const text = event.target.value?.trim() || '';\n try {\n await fetch('/api/v1/notes/with-image', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, text: text || '(image)', imageBase64: base64, source: 'dashboard' }),\n });\n event.target.value = '';\n fetchDashboard();\n } catch (e) {\n console.error('paste-image error:', e);\n }\n };\n reader.readAsDataURL(blob);\n return;\n }\n }\n }\n\n function openNoteImage(src) {\n const lb = document.createElement('div');\n lb.className = 'note-lightbox';\n const img = document.createElement('img');\n img.src = src;\n lb.appendChild(img);\n lb.onclick = () => lb.remove();\n document.body.appendChild(lb);\n }\n\n function editNote(span, noteId) {\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'reply-input';\n input.value = span.textContent;\n input.style.cssText = 'flex:1;font-size:12px;';\n span.replaceWith(input);\n input.focus();\n input.select();\n async function save() {\n const text = input.value.trim();\n if (text && text !== span.textContent) {\n await fetch('/api/v1/notes/' + noteId, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ text }),\n });\n span.textContent = text;\n span.title = text;\n }\n input.replaceWith(span);\n }\n input.onblur = save;\n input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } if (e.key === 'Escape') input.replaceWith(span); };\n }\n\n async function deleteNote(noteId) {\n try {\n await fetch('/api/v1/notes/' + noteId, { method: 'DELETE' });\n fetchDashboard();\n } catch (e) {\n console.error('delete-note error:', e);\n }\n }\n\n async function sendNote(noteId, btn) {\n if (btn) btn.disabled = true;\n const noteText = btn?.closest('.note-item')?.querySelector('.note-text')?.textContent || '';\n try {\n const res = await fetch('/api/v1/notes/' + noteId + '/send', { method: 'POST' });\n const result = await res.json();\n if (!res.ok) {\n showToast(btn, result.error || 'Failed to send', true);\n } else {\n if (result.sessionId) {\n const card = document.querySelector('#title-' + result.sessionId.slice(0, 8))?.closest('.card');\n showSentBanner(card, '\u25B6', noteText);\n }\n showToast(btn, 'Sent', false);\n }\n fetchDashboard();\n } catch (e) {\n console.error('send-note error:', e);\n }\n if (btn) btn.disabled = false;\n }\n\n function getProjectNoteIds(project) {\n return Array.from(document.querySelectorAll('.note-item[data-project=\"' + project + '\"]'))\n .map(el => el.dataset.noteId);\n }\n\n async function saveNoteOrder(project) {\n const orderedIds = getProjectNoteIds(project);\n try {\n await fetch('/api/v1/notes/reorder', {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ project, orderedIds }),\n });\n } catch (e) {\n console.error('reorder error:', e);\n }\n }\n\n function moveNote(project, idx, direction) {\n const items = document.querySelectorAll('.note-item[data-project=\"' + project + '\"]');\n const target = idx + direction;\n if (target < 0 || target >= items.length) return;\n const parent = items[0].parentNode;\n if (direction === -1) parent.insertBefore(items[idx], items[target]);\n else parent.insertBefore(items[target], items[idx]);\n saveNoteOrder(project);\n }\n\n let draggedNote = null;\n function onNoteDragStart(e) {\n draggedNote = e.currentTarget;\n draggedNote.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n }\n function onNoteDragOver(e) {\n e.preventDefault();\n const item = e.currentTarget;\n if (item !== draggedNote) item.classList.add('drag-over');\n }\n function onNoteDrop(e) {\n e.preventDefault();\n const target = e.currentTarget;\n target.classList.remove('drag-over');\n if (!draggedNote || target === draggedNote) return;\n const parent = target.parentNode;\n const items = Array.from(parent.querySelectorAll('.note-item'));\n const fromIdx = items.indexOf(draggedNote);\n const toIdx = items.indexOf(target);\n if (fromIdx < toIdx) parent.insertBefore(draggedNote, target.nextSibling);\n else parent.insertBefore(draggedNote, target);\n saveNoteOrder(draggedNote.dataset.project);\n }\n function onNoteDragEnd(e) {\n document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));\n if (draggedNote) draggedNote.classList.remove('dragging');\n draggedNote = null;\n }\n\n function showToast(anchor, text, isError) {\n const toast = document.createElement('span');\n toast.textContent = (isError ? '\u2717 ' : '\u2713 ') + text;\n toast.style.cssText = 'position:absolute;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600;z-index:10;pointer-events:none;'\n + (isError ? 'background:#490202;color:#f85149;' : 'background:#0d2818;color:#3fb950;');\n if (anchor) {\n const rect = anchor.getBoundingClientRect();\n toast.style.left = rect.left + 'px';\n toast.style.top = (rect.top - 24) + 'px';\n } else {\n toast.style.right = '24px';\n toast.style.top = '70px';\n }\n document.body.appendChild(toast);\n setTimeout(() => toast.remove(), 2000);\n }\n\n async function allowAll() {\n if (!data) return;\n const pending = [];\n for (const p of data.projects) {\n for (const s of p.sessions) {\n if (s.pendingQuestion && s.pendingQuestion.type === 'permission_prompt') {\n pending.push(s.pendingQuestion);\n }\n }\n }\n for (const q of pending) {\n await respondTo(q.eventId, 'allow', null);\n }\n fetchDashboard();\n }\n\n // --- Notification sound ---\n // Tracks pending event ids we already showed; new ones trigger the ding.\n // First render seeds the set without sounding so a page reload doesn't\n // beep for prompts that were already pending.\n let soundEnabled = localStorage.getItem('soundEnabled') !== 'false';\n let audioCtx = null;\n let seenEventIds = new Set();\n let firstSoundCheck = true;\n\n function updateSoundBtn() {\n const btn = document.getElementById('soundBtn');\n if (!btn) return;\n btn.textContent = soundEnabled ? '\uD83D\uDD14' : '\uD83D\uDD15';\n btn.classList.toggle('muted', !soundEnabled);\n }\n\n function initAudio() {\n if (audioCtx) return;\n try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {}\n }\n\n function playNotificationSound() {\n if (!soundEnabled) return;\n initAudio();\n if (!audioCtx) return;\n if (audioCtx.state === 'suspended') audioCtx.resume();\n const t0 = audioCtx.currentTime;\n // Two-note ding: E5 then C5, sine wave with a soft attack + decay so\n // it doesn't pop. Quiet (peak 0.18) \u2014 meant to be noticed, not jarring.\n [659.25, 523.25].forEach((freq, i) => {\n const start = t0 + i * 0.13;\n const osc = audioCtx.createOscillator();\n const gain = audioCtx.createGain();\n osc.type = 'sine';\n osc.frequency.value = freq;\n gain.gain.setValueAtTime(0.0001, start);\n gain.gain.exponentialRampToValueAtTime(0.18, start + 0.02);\n gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.22);\n osc.connect(gain);\n gain.connect(audioCtx.destination);\n osc.start(start);\n osc.stop(start + 0.25);\n });\n }\n\n function toggleSound() {\n initAudio();\n soundEnabled = !soundEnabled;\n localStorage.setItem('soundEnabled', String(soundEnabled));\n updateSoundBtn();\n if (soundEnabled) playNotificationSound(); // sample so the user hears what they enabled\n }\n\n function detectNewPending(data) {\n const current = new Set();\n if (data && Array.isArray(data.projects)) {\n for (const p of data.projects) {\n for (const s of (p.sessions || [])) {\n if (s.pendingQuestion && s.pendingQuestion.eventId) current.add(s.pendingQuestion.eventId);\n }\n }\n }\n if (!firstSoundCheck) {\n for (const id of current) {\n if (!seenEventIds.has(id)) { playNotificationSound(); break; }\n }\n }\n seenEventIds = current;\n firstSoundCheck = false;\n }\n\n // --- Terminal view ---\n let termState = null; // { overlay, term, sessionId, pollTimer }\n\n async function openTerminal(sessionId, pane) {\n if (termState) closeTerminal();\n\n const overlay = document.createElement('div');\n overlay.className = 'term-overlay';\n const keyBtn = (label, k) =>\n '<button class=\"term-key-btn\" data-key=\"' + k + '\" title=\"Send ' + k + '\">' + label + '</button>';\n overlay.innerHTML =\n '<div class=\"term-modal\">'\n + '<div class=\"term-head\">'\n + '<span class=\"term-title\">Terminal</span>'\n + '<span class=\"term-pane\">pane ' + pane + '</span>'\n + '<button class=\"term-close\" title=\"Close\">\u2715</button>'\n + '</div>'\n + '<div class=\"term-body\"><div class=\"term-xterm\"></div></div>'\n + '<div class=\"term-keys-row\">'\n + keyBtn('\u2191', 'Up') + keyBtn('\u2193', 'Down') + keyBtn('\u2190', 'Left') + keyBtn('\u2192', 'Right')\n + keyBtn('\u23CE', 'Enter') + keyBtn('Esc', 'Escape') + keyBtn('Tab', 'Tab')\n + keyBtn('\u232B', 'BSpace') + keyBtn('^C', 'C-c')\n + '</div>'\n + '<div class=\"term-input-row\">'\n + '<input type=\"text\" class=\"term-send\" placeholder=\"Type and press Enter to send to the session...\">'\n + '</div>'\n + '</div>';\n document.body.appendChild(overlay);\n\n const term = new Terminal({\n fontFamily: 'JetBrains Mono, monospace',\n fontSize: 12,\n convertEol: true,\n disableStdin: true,\n theme: { background: '#0d1117', foreground: '#c9d1d9' },\n scrollback: 5000,\n });\n term.open(overlay.querySelector('.term-xterm'));\n\n termState = { overlay, term, sessionId, pollTimer: null };\n\n // Close on \u2715, on click outside the modal, or Escape.\n overlay.querySelector('.term-close').onclick = closeTerminal;\n overlay.onclick = (e) => { if (e.target === overlay) closeTerminal(); };\n document.addEventListener('keydown', onTermEsc);\n\n const sendInput = overlay.querySelector('.term-send');\n sendInput.onkeydown = (e) => {\n if (e.key === 'Enter') {\n sendTerminalKeys(sessionId, sendInput.value);\n sendInput.value = '';\n }\n };\n sendInput.focus();\n\n overlay.querySelectorAll('.term-key-btn').forEach(btn => {\n btn.onclick = (e) => {\n e.preventDefault();\n sendTerminalSpecialKey(sessionId, btn.dataset.key);\n sendInput.focus();\n };\n });\n\n await refreshTerminal();\n termState.pollTimer = setInterval(refreshTerminal, 2000);\n }\n\n function onTermEsc(e) { if (e.key === 'Escape') closeTerminal(); }\n\n function closeTerminal() {\n if (!termState) return;\n clearInterval(termState.pollTimer);\n document.removeEventListener('keydown', onTermEsc);\n termState.term.dispose();\n termState.overlay.remove();\n termState = null;\n }\n\n async function refreshTerminal() {\n if (!termState) return;\n try {\n const res = await fetch('/api/v1/session/' + termState.sessionId + '/terminal');\n if (!res.ok) return;\n const data = await res.json();\n // Snapshot rendering: reset then write the captured buffer (with ANSI).\n termState.term.reset();\n termState.term.write(data.content || '');\n } catch (e) {\n console.error('terminal refresh error:', e);\n }\n }\n\n async function sendTerminalKeys(sessionId, keys) {\n if (!keys) return;\n try {\n await fetch('/api/v1/session/' + sessionId + '/keys', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ keys, enter: true }),\n });\n setTimeout(refreshTerminal, 150);\n } catch (e) {\n console.error('terminal send error:', e);\n }\n }\n\n async function sendTerminalSpecialKey(sessionId, key) {\n try {\n await fetch('/api/v1/session/' + sessionId + '/keys', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ key }),\n });\n setTimeout(refreshTerminal, 150);\n } catch (e) {\n console.error('terminal special-key error:', e);\n }\n }\n\n async function fetchDashboard() {\n try {\n const res = await fetch('/api/v1/dashboard');\n data = await res.json();\n render(data);\n document.getElementById('statusDot').className = 'status-dot connected';\n document.getElementById('lastUpdate').textContent = 'updated ' + timeAgo(data.updatedAt);\n } catch {\n document.getElementById('statusDot').className = 'status-dot disconnected';\n document.getElementById('lastUpdate').textContent = 'disconnected';\n }\n }\n\n updateSoundBtn();\n fetchDashboard();\n\n // SSE for instant push \u2014 polling as fallback\n let fallbackTimer = setInterval(fetchDashboard, 10000);\n function connectSSE() {\n const es = new EventSource('/api/v1/sse');\n es.addEventListener('refresh', () => fetchDashboard());\n es.onopen = () => {\n clearInterval(fallbackTimer);\n fallbackTimer = setInterval(fetchDashboard, 10000);\n };\n es.onerror = () => {\n es.close();\n clearInterval(fallbackTimer);\n fallbackTimer = setInterval(fetchDashboard, 2000);\n setTimeout(connectSSE, 3000);\n };\n }\n connectSSE();\n </script>\n</body>\n</html>";
|
|
2
2
|
//# sourceMappingURL=html.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../src/dashboard/html.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc
|
|
1
|
+
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../src/dashboard/html.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oxzDA+uDnB,CAAC"}
|