dashboard/index.html (72041 bytes)
1 <!doctype html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:"> 7 <meta name="color-scheme" content="light dark"> 8 <title>ideamine</title> 9 <style> 10 /* Theme tokens. Dark mode overrides the same names. */ 11 :root { 12 color-scheme: light dark; 13 --bg: #f9f9f7; 14 --surface: #fcfcfb; 15 --surface-2: #f1f1ee; 16 --text: #0b0b0b; 17 --text-dim: #52514e; 18 --text-muted: #898781; 19 --border: #e1e0d9; 20 --border-strong: #c3c2b7; 21 --accent: #2a78d6; 22 --focus-ring: #2a78d6; 23 --shadow: 0 8px 24px rgba(11, 11, 11, 0.16); 24 --lane-doing: #2a78d6; 25 --lane-do: #1baf7a; 26 --lane-maybe: #eda100; 27 --lane-inbox: #898781; 28 --lane-skip: #4a3aa7; 29 --lane-done: #0ca30c; 30 --lane-dropped: #d03b3b; 31 --phase-inbox: #86b6ef; 32 --phase-queued: #3987e5; 33 --phase-doing: #184f95; 34 --now-line: #0b0b0b; 35 } 36 @media (prefers-color-scheme: dark) { 37 :root { 38 --bg: #0d0d0d; 39 --surface: #1a1a19; 40 --surface-2: #232322; 41 --text: #ffffff; 42 --text-dim: #c3c2b7; 43 --text-muted: #898781; 44 --border: #2c2c2a; 45 --border-strong: #383835; 46 --accent: #3987e5; 47 --focus-ring: #86b6ef; 48 --shadow: 0 8px 24px rgba(0, 0, 0, 0.6); 49 --lane-doing: #3987e5; 50 --lane-do: #199e70; 51 --lane-maybe: #c98500; 52 --lane-inbox: #898781; 53 --lane-skip: #9085e9; 54 --lane-done: #0ca30c; 55 --lane-dropped: #e66767; 56 --phase-inbox: #184f95; 57 --phase-queued: #3987e5; 58 --phase-doing: #86b6ef; 59 --now-line: #ffffff; 60 } 61 } 62 63 * { box-sizing: border-box; } 64 [hidden] { display: none !important; } /* a class with a display value must not show a hidden element */ 65 html, body { margin: 0; padding: 0; } 66 body { 67 background: var(--bg); 68 color: var(--text); 69 font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 70 font-size: 14px; 71 line-height: 1.45; 72 } 73 h1, h2, h3, p, dl, dd { margin: 0; } 74 button { font: inherit; color: inherit; } 75 code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } 76 77 /* header */ 78 .app-header { position: sticky; top: 0; z-index: 5; background: var(--surface); border-bottom: 1px solid var(--border); } 79 .header-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 6px 12px; } 80 .header-top { justify-content: space-between; padding-top: 10px; } 81 .brand { font-size: 18px; font-weight: 700; } 82 .updated { color: var(--text-muted); font-size: 12px; } 83 .counts { padding-top: 0; } 84 .count-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); } 85 .count-chip-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--chip-color); flex: none; } 86 .header-controls { padding-top: 0; padding-bottom: 10px; } 87 .tabs { display: flex; gap: 4px; } 88 .tabs button { padding: 6px 12px; border: 1px solid var(--border); background: var(--surface-2); border-radius: 6px; cursor: pointer; } 89 .tabs button.active { background: var(--accent); border-color: var(--accent); color: #fff; } 90 .parked-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); white-space: nowrap; cursor: pointer; } 91 .search-input { flex: 1 1 160px; min-width: 120px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); } 92 93 /* layout */ 94 main { padding: 12px; } 95 .empty-note { color: var(--text-muted); padding: 24px 4px; } 96 .load-error { padding: 24px; color: var(--lane-dropped); } 97 98 /* board */ 99 .board { display: flex; align-items: flex-start; gap: 12px; overflow-x: auto; padding-bottom: 8px; } 100 .column { flex: 0 0 260px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; } 101 .column-header { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); font-weight: 600; } 102 .column-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--chip-color); flex: none; } 103 .column-title { flex: 1; } 104 .column-count { color: var(--text-muted); font-weight: 400; } 105 .column-body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 12px; } 106 .column-empty { color: var(--text-muted); font-size: 12px; padding: 4px; } 107 108 /* ticket cards */ 109 .card { display: block; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; cursor: pointer; } 110 .card:hover { border-color: var(--border-strong); } 111 .card-compact { padding: 6px 8px; } 112 .card-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; } 113 .card-key { font-size: 11px; color: var(--text-muted); } 114 .card-chips { display: flex; gap: 4px; flex-wrap: wrap; } 115 .card-title { font-size: 13px; } 116 .card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } 117 .card-project { font-size: 11px; color: var(--text-muted); } 118 .card-score { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--accent); } 119 120 /* chips (lane / model / size / impact / tag) */ 121 .chip { display: inline-flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 999px; padding: 1px 8px; font-size: 11px; color: var(--text-dim); background: var(--surface-2); white-space: nowrap; } 122 .chip-lane { border-color: var(--chip-color); color: var(--chip-color); font-weight: 600; background: transparent; } 123 .chip-model { text-transform: capitalize; } 124 .chip-tag::before { content: "#"; opacity: .6; } 125 126 /* groups view */ 127 .group-section { margin-bottom: 20px; } 128 .group-title { font-size: 15px; font-weight: 700; margin-bottom: 8px; } 129 .group-count { color: var(--text-muted); font-weight: 400; } 130 .group-cards { display: flex; flex-wrap: wrap; gap: 8px; } 131 .group-cards .card { width: 220px; } 132 133 /* search results */ 134 .search-mode { color: var(--text-muted); font-size: 12px; margin-bottom: 8px; } 135 .search-list { display: flex; flex-direction: column; gap: 8px; max-width: 520px; } 136 137 /* timeline (gantt) */ 138 .tl-legend { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 6px 2px 10px; font-size: 12px; color: var(--text-dim); } 139 .tl-legend-item { display: inline-flex; align-items: center; gap: 5px; } 140 .tl-legend-swatch { display: inline-block; width: 14px; height: 10px; border-radius: 2px; background: var(--chip-color); } 141 .tl-legend-hatch { background-image: repeating-linear-gradient(45deg, var(--chip-color) 0 2px, transparent 2px 4px); } 142 .tl-legend-mark { font-weight: 700; } 143 .tl-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } 144 .tl-inner { display: flex; align-items: flex-start; width: max-content; } 145 .tl-labels { position: sticky; left: 0; z-index: 2; background: var(--surface); border-right: 1px solid var(--border); flex: 0 0 auto; } 146 .tl-label-header { border-bottom: 1px solid var(--border); } 147 .tl-label-row { display: flex; align-items: center; gap: 6px; width: 220px; padding: 0 8px; border: 0; border-bottom: 1px solid var(--border); background: none; cursor: pointer; text-align: left; } 148 .tl-label-row:hover, .tl-label-row:focus-visible { background: var(--surface-2); } 149 .tl-label-key { font-size: 11px; color: var(--text-muted); flex: none; } 150 .tl-label-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 151 .tl-chart { flex: 0 0 auto; } 152 .tl-grid { stroke: var(--border); stroke-width: 1; } 153 .tl-tick-label { fill: var(--text-muted); font-size: 10px; } 154 .tl-now { stroke: var(--now-line); stroke-width: 1.5; stroke-dasharray: 4 3; } 155 .tl-now-label { fill: var(--now-line); font-size: 10px; } 156 .tl-seg-inbox { fill: var(--phase-inbox); } 157 .tl-seg-queued { fill: var(--phase-queued); } 158 .tl-seg-doing { fill: var(--phase-doing); } 159 .tl-seg-estimated.tl-seg-inbox { fill: url(#hatch-inbox); } 160 .tl-seg-estimated.tl-seg-queued { fill: url(#hatch-queued); } 161 .tl-seg-estimated.tl-seg-doing { fill: url(#hatch-doing); } 162 .tl-seg { cursor: pointer; } 163 .tl-seg:hover, .tl-seg:focus-visible { stroke: var(--text); stroke-width: 1.5; outline: none; } 164 .tl-end-mark { font-size: 13px; font-weight: 700; dominant-baseline: middle; } 165 .tl-end-done { fill: var(--lane-done); } 166 .tl-end-dropped { fill: var(--lane-dropped); } 167 168 /* detail drawer */ 169 .drawer-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, .4); z-index: 20; } 170 .drawer-backdrop[hidden] { display: none; } 171 .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100%; background: var(--surface); box-shadow: var(--shadow); z-index: 21; overflow-y: auto; padding: 16px; } 172 .drawer[hidden] { display: none; } 173 @media (max-width: 640px) { .drawer { width: 100%; } } 174 .drawer-close { position: sticky; top: 0; float: right; background: none; border: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--text-dim); } 175 .drawer-title { font-size: 16px; font-weight: 700; margin: 4px 0 10px; clear: both; } 176 .drawer-key { color: var(--text-muted); font-size: 13px; margin-right: 6px; } 177 .drawer-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; } 178 .drawer-meta { color: var(--text-dim); font-size: 12px; margin-bottom: 10px; } 179 .date-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; font-size: 12px; margin-bottom: 14px; } 180 .date-grid dt { color: var(--text-muted); } 181 .drawer-section { margin-bottom: 16px; } 182 .drawer-section h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-bottom: 6px; } 183 .drawer-text { white-space: pre-wrap; font-size: 13px; } 184 .notes { display: flex; flex-direction: column; gap: 8px; } 185 .note { border-left: 2px solid var(--border-strong); padding-left: 8px; } 186 .note-at { font-size: 11px; color: var(--text-muted); } 187 .note-text { white-space: pre-wrap; font-size: 13px; } 188 .link-button { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0; text-align: left; font: inherit; } 189 .related-list { display: flex; flex-direction: column; gap: 6px; } 190 .related-item { display: flex; align-items: center; gap: 8px; width: 100%; } 191 .related-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } 192 .related-score { color: var(--text-muted); font-size: 11px; } 193 .text-muted { color: var(--text-muted); } 194 195 /* hover / tap preview for timeline bars */ 196 .tooltip { position: fixed; z-index: 30; max-width: 240px; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 6px; padding: 6px 8px; font-size: 12px; box-shadow: var(--shadow); pointer-events: none; } 197 .tooltip[hidden] { display: none; } 198 .tooltip-key { font-weight: 700; } 199 .tooltip-title { color: var(--text-dim); margin: 2px 0; } 200 .tooltip-phase { color: var(--text-muted); } 201 202 /* commands: only on the page of `ideamine serve` */ 203 .header-actions { padding-top: 0; padding-bottom: 10px; } 204 .readonly-note { color: var(--text-muted); font-size: 12px; padding-top: 0; padding-bottom: 8px; } 205 .btn { display: inline-flex; align-items: center; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--surface-2); color: var(--text); font-size: 13px; cursor: pointer; } 206 .btn:hover:not(:disabled) { border-color: var(--text-muted); } 207 .btn:disabled { opacity: .5; cursor: default; } 208 .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } 209 .btn-danger { background: transparent; border-color: var(--lane-dropped); color: var(--lane-dropped); } 210 body.busy .btn, body.busy #watch-toggle, body.busy .model-select { pointer-events: none; opacity: .5; } 211 .panel { display: flex; flex-direction: column; gap: 8px; margin: 12px 12px 0; padding: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; } 212 .panel[hidden] { display: none; } 213 .panel-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } 214 .panel-hint { color: var(--text-muted); font-size: 12px; } 215 textarea, select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; } 216 textarea { width: 100%; resize: vertical; } 217 .answer { white-space: pre-wrap; font-size: 13px; } 218 .answer:empty { display: none; } 219 .drawer-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } 220 .model-label { font-size: 12px; color: var(--text-dim); } 221 .toast { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 40; width: max-content; max-width: min(560px, calc(100% - 32px)); padding: 8px 12px; background: var(--surface-2); border: 1px solid var(--border-strong); border-left: 4px solid var(--lane-done); border-radius: 6px; box-shadow: var(--shadow); font-size: 13px; white-space: pre-wrap; cursor: pointer; } 222 .toast[hidden] { display: none; } 223 .toast-busy { border-left-color: var(--accent); } 224 .toast-error { border-left-color: var(--lane-dropped); } 225 226 /* prompts and memory tabs */ 227 .view-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; font-size: 12px; color: var(--text-dim); } 228 .view-note { color: var(--text-muted); font-size: 12px; } 229 .section-title { font-size: 13px; font-weight: 700; margin: 18px 0 8px; } 230 .row-list { display: flex; flex-direction: column; gap: 6px; max-width: 860px; } 231 .row-item { display: grid; grid-template-columns: 150px 1fr; gap: 10px; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; cursor: pointer; } 232 .row-item:hover { border-color: var(--border-strong); } 233 .row-meta { font-size: 11px; color: var(--text-muted); } 234 .row-text { font-size: 13px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; white-space: pre-wrap; word-break: break-word; } 235 @media (max-width: 640px) { .row-item { grid-template-columns: 1fr; gap: 2px; } } 236 .tl-tick { stroke: var(--text-dim); stroke-width: 2; cursor: pointer; } 237 .tl-tick:hover, .tl-tick:focus-visible { stroke: var(--text); stroke-width: 3; outline: none; } 238 .tl-span { fill: var(--phase-inbox); } 239 .tl-dot { stroke: var(--surface); stroke-width: 1; cursor: pointer; } 240 .tl-dot:hover, .tl-dot:focus-visible { stroke: var(--text); stroke-width: 2; outline: none; } 241 .mem-area { margin-bottom: 14px; } 242 .mem-area h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-bottom: 6px; } 243 .mem-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; } 244 .mem-card { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; cursor: pointer; } 245 .mem-card:hover { border-color: var(--border-strong); } 246 .mem-key { font-size: 12px; font-weight: 600; word-break: break-all; } 247 .mem-preview { font-size: 12px; color: var(--text-dim); white-space: pre-wrap; word-break: break-word; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; margin-top: 4px; } 248 .version { border-left: 2px solid var(--border-strong); padding-left: 8px; margin-bottom: 12px; } 249 250 /* focus */ 251 button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, [tabindex]:focus-visible { 252 outline: 2px solid var(--focus-ring); 253 outline-offset: 2px; 254 } 255 </style> 256 </head> 257 <body> 258 <header class="app-header"> 259 <div class="header-row header-top"> 260 <h1 class="brand">ideamine</h1> 261 <span class="updated" id="updated"></span> 262 </div> 263 <div class="header-row counts" id="counts" aria-label="Idea counts by lane"></div> 264 <div class="header-row header-controls"> 265 <nav class="tabs" id="tabs" role="tablist" aria-label="Views"> 266 <button type="button" role="tab" data-view="board" aria-controls="main">Board</button> 267 <button type="button" role="tab" data-view="timeline" aria-controls="main">Timeline</button> 268 <button type="button" role="tab" data-view="groups" aria-controls="main">Groups</button> 269 <button type="button" role="tab" data-view="prompts" aria-controls="main" hidden>Prompts</button> 270 <button type="button" role="tab" data-view="memory" aria-controls="main" hidden>Memory</button> 271 </nav> 272 <label class="parked-toggle"> 273 <input type="checkbox" id="parked-toggle"> Show parked 274 </label> 275 <input type="search" id="search-input" class="search-input" placeholder="Search ideas…" aria-label="Search ideas"> 276 </div> 277 <div class="header-row header-actions" id="actions" hidden> 278 <button type="button" class="btn btn-primary" id="new-idea" title="Save an idea (/idea)">+ Idea</button> 279 <button type="button" class="btn" id="triage" title="Triage the inbox with one headless Claude call (/ideas-sort)">Triage inbox</button> 280 <button type="button" class="btn" id="build-next" title="Triage new ideas, then open Claude Code on the first idea of the queue (/ideas-go)">Build next</button> 281 <button type="button" class="btn" id="ask-open" title="Ask Claude about your ideas (/ideas <question>)">Ask</button> 282 <label class="parked-toggle" id="watch-label"><input type="checkbox" id="watch-toggle"> Watcher</label> 283 </div> 284 <p class="header-row readonly-note" id="readonly-note" hidden>Read-only copy. For buttons, run /ideas-web in Claude Code on your PC.</p> 285 </header> 286 287 <section class="panel" id="composer" hidden aria-label="New idea"> 288 <textarea id="composer-text" rows="3" placeholder="One idea, or a bulleted list: one idea for each bullet. #tags work." aria-label="Idea text"></textarea> 289 <div class="panel-row"> 290 <button type="button" class="btn btn-primary" id="composer-save">Save</button> 291 <button type="button" class="btn" id="composer-cancel">Cancel</button> 292 <span class="panel-hint">Ctrl+Enter saves.</span> 293 </div> 294 </section> 295 296 <section class="panel" id="ask-panel" hidden aria-label="Ask about your ideas"> 297 <div class="panel-row"> 298 <input type="text" id="ask-input" class="search-input" placeholder="For example: which ideas fit in an hour?" aria-label="Question"> 299 <button type="button" class="btn btn-primary" id="ask-send">Ask</button> 300 <button type="button" class="btn" id="ask-close">Close</button> 301 </div> 302 <div class="answer" id="ask-answer" aria-live="polite"></div> 303 </section> 304 305 <main id="main" role="tabpanel"></main> 306 307 <div class="drawer-backdrop" id="drawer-backdrop" hidden></div> 308 <aside class="drawer" id="drawer" hidden role="dialog" aria-modal="true" aria-labelledby="drawer-title"> 309 <button class="drawer-close" id="drawer-close" type="button" aria-label="Close">×</button> 310 <div id="drawer-body"></div> 311 </aside> 312 313 <div class="tooltip" id="tooltip" hidden role="status"></div> 314 <div class="toast" id="toast" hidden role="status" aria-live="polite" title="Click to close"></div> 315 316 <script> 317 'use strict'; 318 319 /* ideamine dashboard. One file, no build step, no external calls 320 besides data.json and v1/embeddings on the same origin. On the page of 321 `ideamine serve`, data.json has `live`, and the buttons call api/ there. */ 322 323 const SVG_NS = 'http://www.w3.org/2000/svg'; 324 const MIN = 60000, HOUR = 3600000, DAY = 86400000; 325 const TL_STEPS = [MIN, 5 * MIN, 15 * MIN, 30 * MIN, HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 30 * DAY, 90 * DAY]; 326 const TL_ROW_H = 34, TL_HEADER_H = 28, TL_BAR_H = 16; 327 const BOARD_LANES = ['doing', 'do', 'maybe', 'inbox', 'done']; 328 const PARKED_LANES = ['skip', 'dropped']; 329 const ALL_LANES = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped']; 330 const LANE_LABELS = { doing: 'Doing', do: 'Do', maybe: 'Maybe', inbox: 'Inbox', skip: 'Skip', done: 'Done', dropped: 'Dropped' }; 331 const PHASE_LABELS = { inbox: 'Inbox', queued: 'Queued', doing: 'Doing' }; 332 const SEARCH_DEBOUNCE_MS = 300; 333 const EMBED_TIMEOUT_MS = 4000; 334 const MODELS = ['haiku', 'sonnet', 'opus', 'fable']; 335 const OPEN_LANES = ['doing', 'do', 'maybe', 'inbox']; 336 const VIEWS = ['board', 'timeline', 'groups', 'prompts', 'memory']; 337 const PROMPT_RANGES = [[1, 'Last day'], [7, 'Last 7 days'], [30, 'Last 30 days'], [0, 'All']]; 338 // Memory categories share the lane colors, so that the page needs no new colors. 339 const CATEGORY_COLORS = { status: 'doing', decision: 'do', gotcha: 'dropped', config: 'maybe', note: 'inbox', reference: 'skip', learning: 'skip' }; 340 341 const state = { 342 data: null, 343 byId: new Map(), 344 byKey: new Map(), 345 view: 'board', 346 showParked: false, 347 openKey: null, 348 query: '', 349 searchResult: null, 350 busy: false, 351 prompts: { days: 1, list: null, error: null, filter: '' }, 352 memory: { overview: null, error: null, project: null, memories: new Map(), histories: new Map() }, 353 }; 354 355 let searchTimer = null; 356 let searchToken = 0; 357 let lastFocused = null; 358 let toastTimer = null; 359 const vecCache = new Map(); 360 361 const $main = document.getElementById('main'); 362 const $counts = document.getElementById('counts'); 363 const $updated = document.getElementById('updated'); 364 const $tabs = document.getElementById('tabs'); 365 const $parkedToggle = document.getElementById('parked-toggle'); 366 const $searchInput = document.getElementById('search-input'); 367 const $drawer = document.getElementById('drawer'); 368 const $drawerBackdrop = document.getElementById('drawer-backdrop'); 369 const $drawerClose = document.getElementById('drawer-close'); 370 const $drawerBody = document.getElementById('drawer-body'); 371 const $tooltip = document.getElementById('tooltip'); 372 const $actions = document.getElementById('actions'); 373 const $readonlyNote = document.getElementById('readonly-note'); 374 const $newIdea = document.getElementById('new-idea'); 375 const $triage = document.getElementById('triage'); 376 const $buildNext = document.getElementById('build-next'); 377 const $askOpen = document.getElementById('ask-open'); 378 const $watchLabel = document.getElementById('watch-label'); 379 const $watchToggle = document.getElementById('watch-toggle'); 380 const $composer = document.getElementById('composer'); 381 const $composerText = document.getElementById('composer-text'); 382 const $composerSave = document.getElementById('composer-save'); 383 const $composerCancel = document.getElementById('composer-cancel'); 384 const $askPanel = document.getElementById('ask-panel'); 385 const $askInput = document.getElementById('ask-input'); 386 const $askSend = document.getElementById('ask-send'); 387 const $askClose = document.getElementById('ask-close'); 388 const $askAnswer = document.getElementById('ask-answer'); 389 const $toast = document.getElementById('toast'); 390 391 /* ---- small DOM builders. Text children become text nodes, never HTML. ---- */ 392 393 function el(tag, attrs, ...kids) { 394 const node = document.createElement(tag); 395 if (attrs) { 396 for (const [k, v] of Object.entries(attrs)) { 397 if (v == null || v === false) continue; 398 if (k === 'class') node.className = v; 399 else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); 400 else node.setAttribute(k, v === true ? '' : v); 401 } 402 } 403 for (const kid of kids.flat(Infinity)) { 404 if (kid == null || kid === false) continue; 405 node.append(kid.nodeType ? kid : String(kid)); 406 } 407 return node; 408 } 409 410 function svgEl(tag, attrs, ...kids) { 411 const node = document.createElementNS(SVG_NS, tag); 412 if (attrs) { 413 for (const [k, v] of Object.entries(attrs)) { 414 if (v == null || v === false) continue; 415 node.setAttribute(k, v === true ? '' : v); 416 } 417 } 418 for (const kid of kids.flat(Infinity)) { 419 if (kid == null || kid === false) continue; 420 node.append(kid.nodeType ? kid : String(kid)); 421 } 422 return node; 423 } 424 425 /* ---- formatting ---- */ 426 427 // The page text is English, so dates and "5 minutes ago" are English too, whatever the browser locale. 428 const LOCALE = 'en'; 429 430 function fmtDate(iso) { 431 if (!iso) return null; 432 const d = new Date(iso); 433 if (Number.isNaN(d.getTime())) return iso; 434 return d.toLocaleString(LOCALE, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); 435 } 436 437 function fmtRelative(iso) { 438 const then = new Date(iso).getTime(); 439 if (Number.isNaN(then)) return 'at an unknown time'; 440 const diffSec = Math.round((then - Date.now()) / 1000); 441 const rtf = new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' }); 442 const units = [['year', 31536000], ['month', 2592000], ['day', 86400], ['hour', 3600], ['minute', 60], ['second', 1]]; 443 for (const [unit, secs] of units) { 444 if (Math.abs(diffSec) >= secs || unit === 'second') return rtf.format(Math.round(diffSec / secs), unit); 445 } 446 } 447 448 function formatTick(t, step) { 449 const d = new Date(t); 450 if (step < HOUR) return d.toLocaleTimeString(LOCALE, { hour: '2-digit', minute: '2-digit' }); 451 if (step < DAY) return d.toLocaleString(LOCALE, { month: 'short', day: 'numeric', hour: '2-digit' }); 452 if (step < 30 * DAY) return d.toLocaleDateString(LOCALE, { month: 'short', day: 'numeric' }); 453 return d.toLocaleDateString(LOCALE, { month: 'short', year: 'numeric' }); 454 } 455 456 /* ---- vectors: decode base64 little-endian float32, dot product ---- */ 457 458 function decodeVec(b64) { 459 if (!b64) return null; 460 const bin = atob(b64); 461 const bytes = new Uint8Array(bin.length); 462 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); 463 const view = new DataView(bytes.buffer); 464 const n = Math.floor(bin.length / 4); 465 const out = new Float32Array(n); 466 for (let i = 0; i < n; i++) out[i] = view.getFloat32(i * 4, true); 467 return out; 468 } 469 470 function getIdeaVec(idea) { 471 if (!idea.vec) return null; 472 if (vecCache.has(idea.id)) return vecCache.get(idea.id); 473 const v = decodeVec(idea.vec); 474 vecCache.set(idea.id, v); 475 return v; 476 } 477 478 function normalize(arr) { 479 let sum = 0; 480 for (let i = 0; i < arr.length; i++) sum += arr[i] * arr[i]; 481 const norm = Math.sqrt(sum) || 1; 482 const out = new Float64Array(arr.length); 483 for (let i = 0; i < arr.length; i++) out[i] = arr[i] / norm; 484 return out; 485 } 486 487 function dot(a, b) { 488 const n = Math.min(a.length, b.length); 489 let s = 0; 490 for (let i = 0; i < n; i++) s += a[i] * b[i]; 491 return s; 492 } 493 494 function findByKey(key) { return state.byKey.get(key.toUpperCase()) || null; } 495 496 /* ---- ticket card, shared by board, groups, and search results ---- */ 497 498 function ideaCard(idea, opts) { 499 opts = opts || {}; 500 const chips = [el('span', { class: 'chip chip-lane', style: '--chip-color:var(--lane-' + idea.lane + ')' }, LANE_LABELS[idea.lane] || idea.lane)]; 501 if (idea.model) chips.push(el('span', { class: 'chip chip-model' }, idea.model)); 502 if (idea.size) chips.push(el('span', { class: 'chip' }, idea.size.toUpperCase())); 503 if (idea.impact != null) chips.push(el('span', { class: 'chip chip-impact' }, '▲' + idea.impact)); 504 505 const top = el('div', { class: 'card-top' }, el('code', { class: 'card-key' }, idea.key), el('span', { class: 'card-chips' }, chips)); 506 if (opts.scoreLabel) top.append(el('span', { class: 'card-score' }, opts.scoreLabel)); 507 508 const meta = []; 509 if (idea.project) meta.push(el('span', { class: 'card-project' }, idea.project)); 510 for (const t of idea.tags || []) meta.push(el('span', { class: 'chip chip-tag' }, t)); 511 512 return el('button', { class: 'card' + (opts.compact ? ' card-compact' : ''), type: 'button', onclick: () => openTicket(idea.key) }, 513 top, 514 el('div', { class: 'card-title' }, idea.title), 515 meta.length ? el('div', { class: 'card-meta' }, meta) : null); 516 } 517 518 /* ---- header: counts + relative update time ---- */ 519 520 function renderHeader() { 521 $counts.textContent = ''; 522 const counts = state.data.counts || {}; 523 for (const lane of ALL_LANES) { 524 $counts.append(el('span', { class: 'count-chip' }, 525 el('span', { class: 'count-chip-dot', style: '--chip-color:var(--lane-' + lane + ')' }), 526 LANE_LABELS[lane] + ' ' + (counts[lane] ?? 0))); 527 } 528 const sync = state.data.live && state.data.live.sync; 529 let where = ''; 530 if (sync) where = sync.offline ? ' · the server does not answer: copy on this PC' : ' · shared by every machine'; 531 if (sync && sync.waiting) where += ' · ' + sync.waiting + ' waiting'; 532 $updated.textContent = 'updated ' + fmtRelative(state.data.generated) + where; 533 $updated.title = fmtDate(state.data.generated) + (sync ? ' · ' + sync.url + (sync.offline ? ' · ' + sync.offline : '') : ''); 534 } 535 536 function renderTabsActive() { 537 const live = state.data.live || {}; 538 for (const btn of $tabs.querySelectorAll('button')) { 539 const view = btn.dataset.view; 540 if (view === 'prompts') btn.hidden = !live.prompts; 541 if (view === 'memory') btn.hidden = !live.memory; 542 const active = view === state.view; 543 btn.classList.toggle('active', active); 544 btn.setAttribute('aria-selected', String(active)); 545 } 546 } 547 548 /* ---- board view ---- */ 549 550 function renderColumn(lane) { 551 const ideas = state.data.ideas.filter((i) => i.lane === lane).sort((a, b) => a.rank - b.rank); 552 return el('div', { class: 'column' }, 553 el('div', { class: 'column-header' }, 554 el('span', { class: 'column-dot', style: '--chip-color:var(--lane-' + lane + ')' }), 555 el('span', { class: 'column-title' }, LANE_LABELS[lane]), 556 el('span', { class: 'column-count' }, String(ideas.length))), 557 el('div', { class: 'column-body' }, ideas.length ? ideas.map((i) => ideaCard(i)) : el('p', { class: 'column-empty' }, 'No tickets'))); 558 } 559 560 function renderBoard() { 561 $main.textContent = ''; 562 const lanes = state.showParked ? BOARD_LANES.concat(PARKED_LANES) : BOARD_LANES; 563 $main.append(el('div', { class: 'board' }, lanes.map(renderColumn))); 564 } 565 566 /* ---- groups view ---- */ 567 568 function renderGroupSection(label, ideas) { 569 return el('section', { class: 'group-section' }, 570 el('h2', { class: 'group-title' }, label + ' ', el('span', { class: 'group-count' }, '(' + ideas.length + ')')), 571 el('div', { class: 'group-cards' }, ideas.map((i) => ideaCard(i, { compact: true })))); 572 } 573 574 function renderGroups() { 575 $main.textContent = ''; 576 const wrap = el('div', { class: 'groups' }); 577 const grouped = new Set(); 578 for (const g of state.data.groups || []) { 579 const ideas = g.ids.map((id) => state.byId.get(id)).filter(Boolean); 580 for (const id of g.ids) grouped.add(id); 581 wrap.append(renderGroupSection(g.label, ideas)); 582 } 583 const ungrouped = state.data.ideas.filter((i) => !grouped.has(i.id)); 584 if (ungrouped.length) wrap.append(renderGroupSection('Ungrouped', ungrouped)); 585 $main.append(wrap); 586 } 587 588 /* ---- timeline (gantt) view ---- */ 589 590 function chooseStep(spanMs, targetTicks) { 591 for (const step of TL_STEPS) { if (spanMs / step <= targetTicks) return step; } 592 return TL_STEPS[TL_STEPS.length - 1]; 593 } 594 595 function buildTimeScale(domainStart, domainEnd) { 596 const span = Math.max(domainEnd - domainStart, 1); 597 const step = chooseStep(span, 8); 598 const ticksCount = Math.ceil(span / step) + 1; 599 const width = Math.max(640, ticksCount * 90); 600 const pxPerMs = width / span; 601 const ticks = []; 602 const first = Math.ceil(domainStart / step) * step; 603 for (let t = first; t <= domainEnd; t += step) ticks.push(t); 604 return { width, step, ticks, x: (t) => (t - domainStart) * pxPerMs }; 605 } 606 607 function makeHatchPattern(id, colorVar) { 608 return svgEl('pattern', { id, patternUnits: 'userSpaceOnUse', width: 6, height: 6, patternTransform: 'rotate(45)' }, 609 svgEl('rect', { width: 6, height: 6, fill: colorVar, 'fill-opacity': 0.35 }), 610 svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 6, stroke: colorVar, 'stroke-width': 3 })); 611 } 612 613 function tooltipContentForSegment(idea, seg) { 614 const to = seg.to ? fmtDate(seg.to) : 'now'; 615 return el('div', {}, 616 el('div', { class: 'tooltip-key' }, idea.key + (seg.estimated ? ' (estimated)' : '')), 617 el('div', { class: 'tooltip-title' }, idea.title), 618 el('div', { class: 'tooltip-phase' }, (PHASE_LABELS[seg.phase] || seg.phase) + ': ' + fmtDate(seg.from) + ' → ' + to)); 619 } 620 621 function positionTooltip(evt, node) { 622 const rect = node.getBoundingClientRect(); 623 const x = evt.clientX ?? (rect.left + rect.width / 2); 624 const y = evt.clientY ?? rect.top; 625 const pad = 12, tw = 240, th = 90; 626 let left = x + pad, top = y + pad; 627 if (left + tw > window.innerWidth) left = x - tw - pad; 628 if (top + th > window.innerHeight) top = y - th - pad; 629 $tooltip.style.left = Math.max(4, left) + 'px'; 630 $tooltip.style.top = Math.max(4, top) + 'px'; 631 } 632 633 function attachTooltip(node, contentFn) { 634 const show = (evt) => { 635 $tooltip.textContent = ''; 636 $tooltip.append(contentFn()); 637 $tooltip.hidden = false; 638 positionTooltip(evt, node); 639 }; 640 const hide = () => { $tooltip.hidden = true; }; 641 node.addEventListener('mouseenter', show); 642 node.addEventListener('mousemove', (e) => { if (!$tooltip.hidden) positionTooltip(e, node); }); 643 node.addEventListener('mouseleave', hide); 644 node.addEventListener('focus', show); 645 node.addEventListener('blur', hide); 646 node.addEventListener('click', (e) => { e.stopPropagation(); show(e); }); 647 } 648 649 function renderTimelineRow(idea, y, scale) { 650 const g = svgEl('g', { class: 'tl-row' }); 651 const barY = y + (TL_ROW_H - TL_BAR_H) / 2; 652 const phases = idea.phases || []; 653 let endX = null; 654 phases.forEach((seg, i) => { 655 const from = Date.parse(seg.from); 656 if (Number.isNaN(from)) return; 657 const to = seg.to ? Date.parse(seg.to) : Date.now(); 658 let x1 = scale.x(from); 659 let x2 = scale.x(Math.max(to, from)); 660 if (i > 0) x1 += 1; 661 if (i < phases.length - 1) x2 -= 1; 662 const w = Math.max(x2 - x1, 1); 663 const phaseName = seg.phase || 'inbox'; 664 const rect = svgEl('rect', { 665 x: x1, y: barY, width: w, height: TL_BAR_H, rx: 3, 666 class: 'tl-seg tl-seg-' + phaseName + (seg.estimated ? ' tl-seg-estimated' : ''), 667 tabindex: '0', role: 'img', 668 'aria-label': idea.key + ' ' + (PHASE_LABELS[phaseName] || phaseName) + ' from ' + fmtDate(seg.from) + ' to ' + (seg.to ? fmtDate(seg.to) : 'now'), 669 }); 670 attachTooltip(rect, () => tooltipContentForSegment(idea, seg)); 671 g.append(rect); 672 endX = x1 + w; 673 }); 674 if (endX != null && (idea.lane === 'done' || idea.lane === 'dropped')) { 675 const mark = idea.lane === 'done' ? '✓' : '✗'; 676 g.append(svgEl('text', { x: endX + 6, y: y + TL_ROW_H / 2 + 4, class: 'tl-end-mark tl-end-' + idea.lane }, mark)); 677 } 678 return g; 679 } 680 681 function renderTimelineLegend() { 682 function item(colorVar, label, hatch) { 683 return el('span', { class: 'tl-legend-item' }, el('span', { class: 'tl-legend-swatch' + (hatch ? ' tl-legend-hatch' : ''), style: '--chip-color:' + colorVar }), label); 684 } 685 return el('div', { class: 'tl-legend' }, 686 item('var(--phase-inbox)', 'Inbox'), 687 item('var(--phase-queued)', 'Queued'), 688 item('var(--phase-doing)', 'Doing'), 689 item('var(--phase-doing)', 'Estimated', true), 690 el('span', { class: 'tl-legend-item' }, el('span', { class: 'tl-legend-mark', style: 'color:var(--lane-done)' }, '✓'), 'Done'), 691 el('span', { class: 'tl-legend-item' }, el('span', { class: 'tl-legend-mark', style: 'color:var(--lane-dropped)' }, '✗'), 'Dropped')); 692 } 693 694 function renderTimeline() { 695 $main.textContent = ''; 696 const ideas = state.data.ideas 697 .filter((i) => state.showParked || (i.lane !== 'skip' && i.lane !== 'dropped')) 698 .slice() 699 .sort((a, b) => Date.parse(a.created) - Date.parse(b.created)); 700 701 if (!ideas.length) { 702 $main.append(el('p', { class: 'empty-note' }, 'No ideas to show.')); 703 return; 704 } 705 706 const now = Date.now(); 707 let domainStart = Infinity, domainEnd = -Infinity; 708 for (const idea of ideas) { 709 for (const seg of idea.phases || []) { 710 const from = Date.parse(seg.from); 711 const to = seg.to ? Date.parse(seg.to) : now; 712 if (!Number.isNaN(from)) domainStart = Math.min(domainStart, from); 713 if (!Number.isNaN(to)) domainEnd = Math.max(domainEnd, to); 714 } 715 } 716 if (!Number.isFinite(domainStart)) domainStart = now - DAY; 717 domainEnd = Math.max(domainEnd, now); 718 const pad = (domainEnd - domainStart) * 0.03 || HOUR; 719 domainStart -= pad; 720 domainEnd += pad; 721 722 const scale = buildTimeScale(domainStart, domainEnd); 723 const chartHeight = TL_HEADER_H + ideas.length * TL_ROW_H; 724 725 const defs = svgEl('defs', {}, makeHatchPattern('hatch-inbox', 'var(--phase-inbox)'), makeHatchPattern('hatch-queued', 'var(--phase-queued)'), makeHatchPattern('hatch-doing', 'var(--phase-doing)')); 726 727 const axis = svgEl('g', { class: 'tl-axis' }); 728 for (const t of scale.ticks) { 729 const x = scale.x(t); 730 axis.append(svgEl('line', { x1: x, x2: x, y1: TL_HEADER_H, y2: chartHeight, class: 'tl-grid' }), svgEl('text', { x: x + 4, y: TL_HEADER_H - 8, class: 'tl-tick-label' }, formatTick(t, scale.step))); 731 } 732 733 const rows = svgEl('g', { class: 'tl-rows' }); 734 ideas.forEach((idea, i) => rows.append(renderTimelineRow(idea, TL_HEADER_H + i * TL_ROW_H, scale))); 735 736 const svgRoot = svgEl('svg', { width: scale.width, height: chartHeight, viewBox: '0 0 ' + scale.width + ' ' + chartHeight, role: 'img', 'aria-label': 'Idea timeline' }, defs, axis, rows); 737 738 if (now >= domainStart && now <= domainEnd) { 739 const nx = scale.x(now); 740 svgRoot.append(svgEl('line', { x1: nx, x2: nx, y1: 0, y2: chartHeight, class: 'tl-now' }), svgEl('text', { x: nx - 4, y: 11, 'text-anchor': 'end', class: 'tl-now-label' }, 'now')); 741 } 742 743 const labels = el('div', { class: 'tl-labels' }, 744 el('div', { class: 'tl-label-header', style: 'height:' + TL_HEADER_H + 'px' }), 745 ideas.map((idea) => el('button', { class: 'tl-label-row', type: 'button', style: 'height:' + TL_ROW_H + 'px', onclick: () => openTicket(idea.key) }, 746 el('code', { class: 'tl-label-key' }, idea.key), 747 el('span', { class: 'tl-label-title' }, idea.title)))); 748 749 const scroll = el('div', { class: 'tl-scroll' }, el('div', { class: 'tl-inner' }, labels, el('div', { class: 'tl-chart' }, svgRoot))); 750 $main.append(renderTimelineLegend(), scroll); 751 } 752 753 /* ---- search: semantic first, word fallback ---- */ 754 755 function wordSearch(q) { 756 const words = q.toLowerCase().split(/\s+/).filter(Boolean); 757 const items = []; 758 for (const idea of state.data.ideas) { 759 const hay = [idea.title, idea.text, (idea.tags || []).join(' '), idea.brief].filter(Boolean).join(' \n ').toLowerCase(); 760 let score = 0; 761 for (const w of words) { if (hay.includes(w)) score++; } 762 if (score >= 1) items.push({ idea, score }); 763 } 764 items.sort((a, b) => b.score - a.score); 765 return { mode: 'words', items }; 766 } 767 768 async function trySemanticSearch(q, embed) { 769 const ctrl = new AbortController(); 770 const timer = setTimeout(() => ctrl.abort(), EMBED_TIMEOUT_MS); 771 try { 772 const res = await fetch('v1/embeddings', { 773 method: 'POST', 774 headers: { 'Content-Type': 'application/json' }, 775 body: JSON.stringify({ model: embed.model, input: [embed.query_prefix + q] }), 776 signal: ctrl.signal, 777 }); 778 if (!res.ok) return null; 779 const json = await res.json(); 780 const raw = json && json.data && json.data[0] && json.data[0].embedding; 781 if (!Array.isArray(raw) || raw.length === 0) return null; 782 const qvec = normalize(raw); 783 const threshold = embed.search_threshold ?? 0.5; 784 const items = []; 785 for (const idea of state.data.ideas) { 786 const vec = getIdeaVec(idea); 787 if (!vec) continue; 788 const score = dot(qvec, vec); 789 if (score >= threshold) items.push({ idea, score }); 790 } 791 items.sort((a, b) => b.score - a.score); 792 return { mode: 'meaning', items }; 793 } catch (err) { 794 return null; 795 } finally { 796 clearTimeout(timer); 797 } 798 } 799 800 async function runSearch(query) { 801 const q = query.trim(); 802 if (!q) { state.searchResult = null; render(); return; } 803 const token = ++searchToken; 804 state.searchResult = null; 805 render(); 806 const embed = state.data.embed || {}; 807 const hasVecs = state.data.ideas.some((i) => i.vec); 808 let result = null; 809 if (embed.available && hasVecs) result = await trySemanticSearch(q, embed); 810 if (token !== searchToken) return; 811 if (!result) result = wordSearch(q); 812 state.searchResult = result; 813 render(); 814 } 815 816 function searchResultCard(item) { 817 const label = state.searchResult.mode === 'meaning' ? Math.round(item.score * 100) + '%' : item.score + (item.score === 1 ? ' word' : ' words'); 818 return ideaCard(item.idea, { compact: true, scoreLabel: label }); 819 } 820 821 function renderSearch() { 822 $main.textContent = ''; 823 if (!state.searchResult) { $main.append(el('p', { class: 'empty-note' }, 'Searching…')); return; } 824 const { mode, items } = state.searchResult; 825 const modeLabel = mode === 'meaning' ? 'Showing results by meaning' : 'Showing results by words, meaning search is not available'; 826 const wrap = el('div', { class: 'search-results' }, el('p', { class: 'search-mode' }, modeLabel)); 827 if (!items.length) wrap.append(el('p', { class: 'empty-note' }, 'No matching ideas.')); 828 else wrap.append(el('div', { class: 'search-list' }, items.map(searchResultCard))); 829 $main.append(wrap); 830 } 831 832 /* ---- shared by the prompts and memory tabs ---- */ 833 834 async function getJson(url) { 835 const res = await fetch(url, { cache: 'no-store' }); 836 let json = null; 837 try { json = await res.json(); } catch (err) { json = null; } 838 if (!res.ok || !json || !json.ok) throw new Error((json && json.error) || 'HTTP ' + res.status); 839 return json; 840 } 841 842 function baseName(dir) { return String(dir || '').split(/[\\/]+/).filter(Boolean).pop() || 'no folder'; } 843 844 const isoOf = (seconds) => new Date(seconds * 1000).toISOString(); 845 846 /** A timeline with one row per item: a span bar, and a mark for each event in the row. */ 847 function renderRowsChart({ rows, label, count, span, marks, onRow, ariaLabel }) { 848 const now = Date.now(); 849 let start = Infinity, end = -Infinity; 850 for (const row of rows) { 851 const [from, to] = span(row); 852 start = Math.min(start, from); 853 end = Math.max(end, to); 854 } 855 end = Math.max(end, start + 10 * MIN); 856 const pad = (end - start) * 0.03; 857 start -= pad; 858 end += pad; 859 const scale = buildTimeScale(start, end); 860 const height = TL_HEADER_H + rows.length * TL_ROW_H; 861 const axis = svgEl('g', {}); 862 for (const t of scale.ticks) { 863 const x = scale.x(t); 864 axis.append(svgEl('line', { x1: x, x2: x, y1: TL_HEADER_H, y2: height, class: 'tl-grid' }), svgEl('text', { x: x + 4, y: TL_HEADER_H - 8, class: 'tl-tick-label' }, formatTick(t, scale.step))); 865 } 866 const body = svgEl('g', {}); 867 rows.forEach((row, i) => { 868 const y = TL_HEADER_H + i * TL_ROW_H; 869 const barY = y + (TL_ROW_H - TL_BAR_H) / 2; 870 const [from, to] = span(row); 871 const x1 = scale.x(from); 872 body.append(svgEl('rect', { x: x1, y: barY, width: Math.max(scale.x(to) - x1, 3), height: TL_BAR_H, rx: 3, class: 'tl-span' })); 873 for (const node of marks(row, scale, barY)) body.append(node); 874 }); 875 const svg = svgEl('svg', { width: scale.width, height, viewBox: '0 0 ' + scale.width + ' ' + height, role: 'img', 'aria-label': ariaLabel }, axis, body); 876 if (now >= start && now <= end) { 877 const nx = scale.x(now); 878 svg.append(svgEl('line', { x1: nx, x2: nx, y1: 0, y2: height, class: 'tl-now' }), svgEl('text', { x: nx - 4, y: 11, 'text-anchor': 'end', class: 'tl-now-label' }, 'now')); 879 } 880 const labels = el('div', { class: 'tl-labels' }, 881 el('div', { class: 'tl-label-header', style: 'height:' + TL_HEADER_H + 'px' }), 882 rows.map((row) => el('button', { class: 'tl-label-row', type: 'button', style: 'height:' + TL_ROW_H + 'px', onclick: () => onRow(row) }, 883 el('code', { class: 'tl-label-key' }, count(row)), 884 el('span', { class: 'tl-label-title' }, label(row))))); 885 return el('div', { class: 'tl-scroll' }, el('div', { class: 'tl-inner' }, labels, el('div', { class: 'tl-chart' }, svg))); 886 } 887 888 /* ---- prompts tab: the prompt log, one row for each Claude Code session ---- */ 889 890 async function loadPrompts() { 891 const p = state.prompts; 892 p.loading = true; 893 const days = p.days; 894 try { 895 const list = (await getJson('api/prompts?days=' + days)).prompts; 896 if (days === p.days) p.list = list; // the range did not change during the load 897 p.error = null; 898 } catch (err) { 899 p.list ||= []; 900 p.error = err.message; 901 } 902 p.loading = false; 903 render(); 904 } 905 906 const sessionOf = (q) => q.session || (q.host || 'unknown') + ' ' + q.at.slice(0, 10); 907 908 /** One session for each session id, with its prompts in time order. The newest session comes first. */ 909 function sessionsOf(prompts) { 910 const byId = new Map(); 911 for (const q of prompts) { 912 const id = sessionOf(q); 913 if (!byId.has(id)) byId.set(id, { id, host: q.host, cwd: q.cwd, prompts: [] }); 914 const s = byId.get(id); 915 s.prompts.push(q); 916 if (q.cwd) s.cwd = q.cwd; 917 } 918 const sessions = [...byId.values()]; 919 for (const s of sessions) { 920 s.from = Date.parse(s.prompts[0].at); 921 s.to = Date.parse(s.prompts[s.prompts.length - 1].at); 922 } 923 return sessions.sort((a, b) => b.to - a.to); 924 } 925 926 // A chat without a project folder runs in a scratch folder of the desktop app. Its first prompt 927 // says more than the folder name. 928 function sessionLabel(s) { 929 const name = baseName(s.cwd); 930 const first = s.prompts && s.prompts[0]; 931 const label = /^scratch-\d{4}-\d{2}-\d{2}-/.test(name) && first ? '“' + first.prompt.replace(/\s+/g, ' ').slice(0, 60) + '”' : name; 932 return label + (s.host ? ' · ' + s.host : ''); 933 } 934 935 function matchesPromptFilter(q) { 936 const f = state.prompts.filter.trim().toLowerCase(); 937 return !f || q.prompt.toLowerCase().includes(f) || String(q.cwd || '').toLowerCase().includes(f); 938 } 939 940 function renderPromptBody(body) { 941 body.textContent = ''; 942 const p = state.prompts; 943 if (p.error) body.append(el('p', { class: 'load-error' }, 'Could not load the prompts: ' + p.error)); 944 const list = p.list.filter(matchesPromptFilter); 945 if (!list.length) { 946 if (!p.error) body.append(el('p', { class: 'empty-note' }, 'No prompts in this time range.')); 947 return; 948 } 949 const sessions = sessionsOf(list); 950 body.append(el('p', { class: 'view-note' }, list.length + ' prompts in ' + sessions.length + ' sessions. A mark is one prompt.')); 951 body.append(renderRowsChart({ 952 rows: sessions, 953 ariaLabel: 'Sessions and their prompts', 954 label: sessionLabel, 955 count: (s) => String(s.prompts.length), 956 span: (s) => [s.from, s.to], 957 onRow: (s) => openTicket('SESSION:' + s.id), 958 marks: (s, scale, barY) => s.prompts.map((q) => { 959 const x = scale.x(Date.parse(q.at)); 960 const tick = svgEl('line', { x1: x, x2: x, y1: barY - 3, y2: barY + TL_BAR_H + 3, class: 'tl-tick', tabindex: '0', role: 'img', 'aria-label': fmtDate(q.at) + ': ' + q.prompt.slice(0, 80) }); 961 attachTooltip(tick, () => el('div', {}, el('div', { class: 'tooltip-key' }, fmtDate(q.at)), el('div', { class: 'tooltip-title' }, q.prompt.slice(0, 200)))); 962 tick.addEventListener('click', () => openTicket('SESSION:' + s.id)); 963 return tick; 964 }), 965 })); 966 body.append(el('h2', { class: 'section-title' }, 'Latest prompts')); 967 body.append(el('div', { class: 'row-list' }, list.slice(-30).reverse().map((q) => 968 el('button', { type: 'button', class: 'row-item', onclick: () => openTicket('SESSION:' + sessionOf(q)) }, 969 el('span', { class: 'row-meta' }, fmtDate(q.at), el('br'), baseName(q.cwd) + (q.host ? ' · ' + q.host : '')), 970 el('span', { class: 'row-text' }, q.prompt))))); 971 } 972 973 function renderPrompts() { 974 $main.textContent = ''; 975 const p = state.prompts; 976 const range = el('select', { 'aria-label': 'Time range' }, PROMPT_RANGES.map(([days, label]) => el('option', { value: String(days), selected: days === p.days }, label))); 977 range.addEventListener('change', () => { p.days = Number(range.value); p.list = null; render(); }); 978 const filter = el('input', { type: 'search', class: 'search-input', placeholder: 'Filter prompts…', 'aria-label': 'Filter prompts' }); 979 filter.value = p.filter; 980 const body = el('div', {}); 981 // Only the list below the filter changes while you type, so the filter keeps the focus. 982 filter.addEventListener('input', () => { p.filter = filter.value; if (p.list) renderPromptBody(body); }); 983 $main.append(el('div', { class: 'view-controls' }, range, filter), body); 984 if (!p.list) body.append(el('p', { class: 'empty-note' }, 'Loading the prompts…')); 985 else renderPromptBody(body); 986 } 987 988 function buildSessionDrawer(id) { 989 const frag = document.createDocumentFragment(); 990 const list = (state.prompts.list || []).filter((q) => sessionOf(q) === id); 991 if (!list.length) { 992 frag.append(el('h2', { id: 'drawer-title', class: 'drawer-title' }, 'Session'), 993 el('p', { class: 'text-muted' }, state.prompts.list ? 'This session has no prompts in the time range.' : 'Loading the prompts…')); 994 return frag; 995 } 996 const first = list[0]; 997 frag.append(el('h2', { id: 'drawer-title', class: 'drawer-title' }, sessionLabel({ cwd: first.cwd, host: first.host, prompts: list }))); 998 frag.append(el('p', { class: 'drawer-meta' }, [first.cwd, first.session && 'session ' + first.session].filter(Boolean).join(' · '))); 999 frag.append(el('p', { class: 'drawer-meta' }, fmtDate(first.at) + ' → ' + fmtDate(list[list.length - 1].at) + ' · ' + list.length + ' prompts')); 1000 const notes = el('div', { class: 'notes' }); 1001 for (const q of list) notes.append(el('div', { class: 'note' }, el('div', { class: 'note-at' }, fmtDate(q.at)), el('div', { class: 'note-text' }, q.prompt))); 1002 frag.append(el('section', { class: 'drawer-section' }, el('h3', {}, 'Prompts'), notes)); 1003 return frag; 1004 } 1005 1006 /* ---- memory tab: what the memstate memories say, and when Claude wrote them ---- */ 1007 1008 /** The projects and memories. With `refresh`, the texts and versions that the page has load again too. */ 1009 async function loadMemoryOverview(opts) { 1010 const m = state.memory; 1011 m.loading = true; 1012 try { 1013 m.overview = await getJson('api/memory/overview'); 1014 m.error = null; 1015 if (opts && opts.refresh) { 1016 m.memories.clear(); 1017 m.histories.clear(); 1018 } 1019 } catch (err) { 1020 m.overview ||= { projects: [], memories: [] }; 1021 m.error = err.message; 1022 } 1023 m.loading = false; 1024 render(); 1025 } 1026 1027 async function loadInto(map, key, url, pick) { 1028 if (map.has(key)) return; 1029 map.set(key, null); // loading 1030 try { map.set(key, pick(await getJson(url))); } catch (err) { map.set(key, { error: err.message }); } 1031 render(); 1032 } 1033 1034 function categoryChip(category) { 1035 const lane = CATEGORY_COLORS[category]; 1036 return el('span', { class: 'chip chip-lane', style: '--chip-color:' + (lane ? 'var(--lane-' + lane + ')' : 'var(--text-muted)') }, category || 'memory'); 1037 } 1038 1039 const memKey = (project, keypath) => 'MEM:' + project + '|' + keypath; 1040 1041 function selectProject(id) { 1042 state.memory.project = id; 1043 render(); 1044 const target = document.getElementById('mem-project'); 1045 if (target) target.scrollIntoView({ block: 'start' }); 1046 } 1047 1048 function renderProjectMemories(id) { 1049 const wrap = el('section', { id: 'mem-project' }, el('h2', { class: 'section-title' }, 'Memories of ' + id)); 1050 const url = 'api/memory/project?id=' + encodeURIComponent(id); 1051 const list = state.memory.memories.get(id); 1052 if (list === undefined) loadInto(state.memory.memories, id, url, (json) => json.memories); 1053 if (!list) { wrap.append(el('p', { class: 'empty-note' }, 'Loading…')); return wrap; } 1054 if (list.error) { wrap.append(el('p', { class: 'load-error' }, list.error)); return wrap; } 1055 const areas = new Map(); 1056 for (const x of list) { 1057 const area = x.keypath.split('.')[0]; 1058 if (!areas.has(area)) areas.set(area, []); 1059 areas.get(area).push(x); 1060 } 1061 for (const [area, items] of areas) { 1062 items.sort((a, b) => b.created_at - a.created_at); 1063 wrap.append(el('div', { class: 'mem-area' }, el('h3', {}, area), el('div', { class: 'mem-cards' }, items.map((x) => 1064 el('button', { type: 'button', class: 'mem-card', onclick: () => openTicket(memKey(id, x.keypath)) }, 1065 el('div', { class: 'card-top' }, categoryChip(x.category), el('span', { class: 'card-key' }, 'v' + x.version + ' · ' + fmtRelative(isoOf(x.created_at)))), 1066 el('div', { class: 'mem-key' }, x.keypath), 1067 el('div', { class: 'mem-preview' }, x.content || '')))))); 1068 } 1069 return wrap; 1070 } 1071 1072 function renderMemory() { 1073 $main.textContent = ''; 1074 const m = state.memory; 1075 if (!m.overview) { $main.append(el('p', { class: 'empty-note' }, 'Loading the memories…')); return; } 1076 if (m.error) $main.append(el('p', { class: 'load-error' }, 'Could not load the memories: ' + m.error)); 1077 const { projects, memories } = m.overview; 1078 if (!projects.length) { if (!m.error) $main.append(el('p', { class: 'empty-note' }, 'memstate has no projects yet.')); return; } 1079 const sorted = projects.slice().sort((a, b) => (b.last_updated_at || b.created_at) - (a.last_updated_at || a.created_at)); 1080 const byProject = new Map(sorted.map((p) => [p.id, []])); 1081 for (const x of memories) if (byProject.has(x.project_id)) byProject.get(x.project_id).push(x); 1082 $main.append(el('p', { class: 'view-note' }, projects.length + ' projects · ' + memories.length + ' memories. A dot is the last write of a memory.')); 1083 $main.append(renderRowsChart({ 1084 rows: sorted, 1085 ariaLabel: 'Projects and their memory writes', 1086 label: (p) => p.id, 1087 count: (p) => String(p.memory_count), 1088 span: (p) => [p.created_at * 1000, (p.last_updated_at || p.created_at) * 1000], 1089 onRow: (p) => selectProject(p.id), 1090 marks: (p, scale, barY) => byProject.get(p.id).map((x) => { 1091 const lane = CATEGORY_COLORS[x.category]; 1092 const dot = svgEl('circle', { cx: scale.x(x.created_at * 1000), cy: barY + TL_BAR_H / 2, r: 5, class: 'tl-dot', fill: lane ? 'var(--lane-' + lane + ')' : 'var(--text-muted)', tabindex: '0', role: 'img', 'aria-label': x.keypath + ', ' + fmtDate(isoOf(x.created_at)) }); 1093 attachTooltip(dot, () => el('div', {}, el('div', { class: 'tooltip-key' }, x.keypath), el('div', { class: 'tooltip-title' }, p.id), el('div', { class: 'tooltip-phase' }, (x.category || 'memory') + ' · v' + x.version + ' · ' + fmtDate(isoOf(x.created_at))))); 1094 dot.addEventListener('click', () => openTicket(memKey(p.id, x.keypath))); 1095 return dot; 1096 }), 1097 })); 1098 $main.append(el('h2', { class: 'section-title' }, 'Latest writes')); 1099 const latest = memories.slice().sort((a, b) => b.created_at - a.created_at).slice(0, 12); 1100 $main.append(el('div', { class: 'row-list' }, latest.map((x) => 1101 el('button', { type: 'button', class: 'row-item', onclick: () => openTicket(memKey(x.project_id, x.keypath)) }, 1102 el('span', { class: 'row-meta' }, fmtDate(isoOf(x.created_at)), el('br'), x.project_id), 1103 el('span', { class: 'row-text' }, categoryChip(x.category), ' ', x.keypath, x.version > 1 ? ' · v' + x.version : ''))))); 1104 if (!m.project || !byProject.has(m.project)) m.project = sorted[0].id; 1105 $main.append(renderProjectMemories(m.project)); 1106 } 1107 1108 function buildMemoryDrawer(key) { 1109 const cut = key.indexOf('|'); 1110 const project = key.slice(0, cut); 1111 const keypath = key.slice(cut + 1); 1112 const url = 'api/memory/history?project=' + encodeURIComponent(project) + '&keypath=' + encodeURIComponent(keypath); 1113 const versions = state.memory.histories.get(key); 1114 if (versions === undefined) loadInto(state.memory.histories, key, url, (json) => json.versions); 1115 const frag = document.createDocumentFragment(); 1116 frag.append(el('h2', { id: 'drawer-title', class: 'drawer-title' }, keypath), el('p', { class: 'drawer-meta' }, 'memstate project ' + project)); 1117 if (!versions) { frag.append(el('p', { class: 'text-muted' }, 'Loading…')); return frag; } 1118 if (versions.error) { frag.append(el('p', { class: 'load-error' }, versions.error)); return frag; } 1119 const list = el('div', {}); 1120 for (const v of versions.slice().reverse()) { 1121 list.append(el('div', { class: 'version' }, 1122 el('div', { class: 'drawer-chips' }, categoryChip(v.category), el('span', { class: 'chip' }, 'v' + v.version), el('span', { class: 'note-at' }, fmtDate(isoOf(v.created_at)))), 1123 v.source ? el('p', { class: 'drawer-meta' }, v.source) : null, 1124 el('p', { class: 'drawer-text' }, v.tombstone ? '(deleted)' : v.content))); 1125 } 1126 frag.append(el('section', { class: 'drawer-section' }, el('h3', {}, versions.length + (versions.length === 1 ? ' version' : ' versions') + ', newest first'), list)); 1127 return frag; 1128 } 1129 1130 /* ---- detail drawer ---- */ 1131 1132 function renderDateGrid(idea) { 1133 const grid = el('dl', { class: 'date-grid' }); 1134 for (const [label, value] of [['Created', idea.created], ['Triaged', idea.triaged], ['Started', idea.started], ['Closed', idea.closed]]) { 1135 grid.append(el('dt', {}, label), el('dd', {}, value ? fmtDate(value) : '—')); 1136 } 1137 return grid; 1138 } 1139 1140 function renderNotes(notes) { 1141 if (!notes || !notes.length) return null; 1142 const list = el('div', { class: 'notes' }); 1143 for (const note of notes) list.append(el('div', { class: 'note' }, el('div', { class: 'note-at' }, fmtDate(note.at)), el('div', { class: 'note-text' }, note.text))); 1144 return el('section', { class: 'drawer-section' }, el('h3', {}, 'Notes'), list); 1145 } 1146 1147 function renderDupOf(dupId) { 1148 const target = state.byId.get(dupId); 1149 const key = target ? target.key : 'IDEA-' + dupId; 1150 const content = target 1151 ? el('button', { class: 'link-button', type: 'button', onclick: () => openTicket(target.key) }, key + ' — ' + target.title) 1152 : el('span', { class: 'text-muted' }, key + ' (not found)'); 1153 return el('section', { class: 'drawer-section' }, el('h3', {}, 'Duplicate of'), content); 1154 } 1155 1156 function renderRelated(related) { 1157 const list = el('div', { class: 'related-list' }); 1158 for (const r of related) { 1159 const target = state.byId.get(r.id); 1160 if (!target) continue; 1161 const pct = Math.round((r.score ?? 0) * 100) + '%'; 1162 list.append(el('button', { class: 'link-button related-item', type: 'button', onclick: () => openTicket(target.key) }, 1163 el('code', { class: 'card-key' }, target.key), el('span', { class: 'related-title' }, target.title), el('span', { class: 'related-score' }, pct))); 1164 } 1165 if (!list.childNodes.length) return null; 1166 return el('section', { class: 'drawer-section' }, el('h3', {}, 'Related'), list); 1167 } 1168 1169 function buildDrawerContent(idea) { 1170 const frag = document.createDocumentFragment(); 1171 const chips = el('div', { class: 'drawer-chips' }, el('span', { class: 'chip chip-lane', style: '--chip-color:var(--lane-' + idea.lane + ')' }, LANE_LABELS[idea.lane] || idea.lane)); 1172 if (idea.model) chips.append(el('span', { class: 'chip chip-model' }, idea.model)); 1173 if (idea.size) chips.append(el('span', { class: 'chip' }, idea.size.toUpperCase())); 1174 if (idea.impact != null) chips.append(el('span', { class: 'chip chip-impact' }, '▲' + idea.impact)); 1175 if (idea.verdict) chips.append(el('span', { class: 'chip' }, 'verdict: ' + idea.verdict)); 1176 1177 frag.append(el('h2', { id: 'drawer-title', class: 'drawer-title' }, el('code', { class: 'drawer-key' }, idea.key), idea.title), chips); 1178 1179 const metaLines = []; 1180 if (idea.project) metaLines.push('Project: ' + idea.project); 1181 if (idea.tags && idea.tags.length) metaLines.push('Tags: ' + idea.tags.join(', ')); 1182 if (metaLines.length) frag.append(el('p', { class: 'drawer-meta' }, metaLines.join(' · '))); 1183 if (isLive()) frag.append(renderDrawerActions(idea)); 1184 1185 frag.append(renderDateGrid(idea)); 1186 1187 if (idea.text) frag.append(el('section', { class: 'drawer-section' }, el('h3', {}, 'Text'), el('p', { class: 'drawer-text' }, idea.text))); 1188 if (idea.why) frag.append(el('section', { class: 'drawer-section' }, el('h3', {}, 'Why'), el('p', { class: 'drawer-text' }, idea.why))); 1189 if (idea.brief) frag.append(el('section', { class: 'drawer-section' }, el('h3', {}, 'Build brief'), el('p', { class: 'drawer-text' }, idea.brief))); 1190 1191 const notes = renderNotes(idea.notes); 1192 if (notes) frag.append(notes); 1193 if (idea.dup_of != null) frag.append(renderDupOf(idea.dup_of)); 1194 if (idea.related && idea.related.length) { 1195 const related = renderRelated(idea.related); 1196 if (related) frag.append(related); 1197 } 1198 return frag; 1199 } 1200 1201 /** The content of the open drawer: a ticket, a session of the prompt log, or a memory. */ 1202 function drawerContent(key) { 1203 if (!key) return null; 1204 if (key.startsWith('SESSION:')) return buildSessionDrawer(key.slice('SESSION:'.length)); 1205 if (key.startsWith('MEM:')) return buildMemoryDrawer(key.slice('MEM:'.length)); 1206 const idea = findByKey(key); 1207 return idea ? buildDrawerContent(idea) : null; 1208 } 1209 1210 function renderDrawer() { 1211 const content = drawerContent(state.openKey); 1212 const wasHidden = $drawer.hidden; 1213 if (!content) { 1214 if (!wasHidden) { 1215 $drawer.hidden = true; 1216 $drawerBackdrop.hidden = true; 1217 if (lastFocused && document.contains(lastFocused)) lastFocused.focus(); 1218 lastFocused = null; 1219 } 1220 return; 1221 } 1222 if (wasHidden) lastFocused = document.activeElement; 1223 $drawer.hidden = false; 1224 $drawerBackdrop.hidden = false; 1225 $drawerBody.textContent = ''; 1226 $drawerBody.append(content); 1227 if (wasHidden) requestAnimationFrame(() => $drawerClose.focus()); 1228 } 1229 1230 function openTicket(key) { location.hash = key; } 1231 function closeDrawer() { location.hash = state.view; } 1232 1233 /* ---- commands: the slash commands as buttons, on the page of `ideamine serve` ---- */ 1234 1235 function isLive() { return !!(state.data && state.data.live); } 1236 1237 function showToast(text, kind) { 1238 clearTimeout(toastTimer); 1239 $toast.textContent = text; 1240 $toast.className = 'toast toast-' + kind; 1241 $toast.hidden = false; 1242 if (kind !== 'busy') toastTimer = setTimeout(() => { $toast.hidden = true; }, kind === 'error' ? 15000 : 8000); 1243 } 1244 1245 async function callApi(action, body) { 1246 const res = await fetch('api/' + action, { 1247 method: 'POST', 1248 headers: { 'Content-Type': 'application/json' }, 1249 body: JSON.stringify(body || {}), 1250 }); 1251 let json = null; 1252 try { json = await res.json(); } catch (err) { json = null; } 1253 if (!res.ok || !json || !json.ok) throw new Error((json && json.error) || 'HTTP ' + res.status); 1254 return json.message; 1255 } 1256 1257 function setBusy(on) { 1258 state.busy = on; 1259 document.body.classList.toggle('busy', on); 1260 $main.setAttribute('aria-busy', String(on)); 1261 } 1262 1263 /** Run one command, show its answer, and load the new data. Resolves to true when the command worked. */ 1264 async function runAction(action, body, busyText) { 1265 if (state.busy) return false; 1266 setBusy(true); 1267 showToast(busyText || 'Working…', 'busy'); 1268 let worked = false; 1269 try { 1270 const message = await callApi(action, body); 1271 worked = true; 1272 showToast(message, 'ok'); 1273 await loadData(); 1274 } catch (err) { 1275 showToast(worked ? 'Done, but the page could not load the new data: ' + err.message : err.message, 'error'); 1276 } 1277 setBusy(false); 1278 if (/^IDEA-/.test(String(state.openKey)) && !findByKey(state.openKey)) { 1279 // The idea of the open ticket is gone: close the ticket without a new history entry. 1280 history.replaceState(null, '', '#' + state.view); 1281 state.openKey = null; 1282 } 1283 render(); 1284 if (!$drawer.hidden && !$drawer.contains(document.activeElement)) $drawerClose.focus(); 1285 if (worked && state.query.trim()) runSearch(state.query); 1286 return worked; 1287 } 1288 1289 function renderToolbar() { 1290 const live = isLive(); 1291 $actions.hidden = !live; 1292 $readonlyNote.hidden = live; 1293 if (!live) return; 1294 const inbox = (state.data.counts && state.data.counts.inbox) || 0; 1295 // Triage, Build, Ask, and the watcher need the Claude Code login: a server without it hides them. 1296 const claude = !!state.data.live.claude; 1297 $triage.textContent = 'Triage inbox (' + inbox + ')'; 1298 $triage.disabled = inbox === 0; 1299 $triage.hidden = !claude; 1300 $buildNext.hidden = !(claude && state.data.live.window); 1301 $askOpen.hidden = !claude; 1302 $watchLabel.hidden = !claude; 1303 const watch = state.data.live.watch || {}; 1304 $watchToggle.checked = !!watch.on; 1305 $watchLabel.title = watch.status || ''; 1306 } 1307 1308 function actionButton(label, kind, onClick, title) { 1309 return el('button', { type: 'button', class: 'btn' + (kind ? ' btn-' + kind : ''), title: title || null, onclick: onClick }, label); 1310 } 1311 1312 function renderDrawerActions(idea) { 1313 const id = idea.id; 1314 const open = OPEN_LANES.includes(idea.lane); 1315 const closed = idea.lane === 'done' || idea.lane === 'dropped'; 1316 const note = el('textarea', { rows: '2', placeholder: 'Note. Done and Drop save it too.', 'aria-label': 'Note' }); 1317 const noteText = () => note.value.trim() || undefined; 1318 const update = (patch) => runAction('update', Object.assign({ id }, patch)); 1319 1320 const buttons = []; 1321 if (!closed && state.data.live.window && state.data.live.claude) { 1322 buttons.push(actionButton('Build with Claude', 'primary', () => runAction('go', { id }, 'Opening Claude Code for ' + idea.key + '…'), 1323 'Open Claude Code on the recommended model, in the project of the idea (/ideas-go ' + id + ')')); 1324 } 1325 if (!closed && idea.lane !== 'doing') buttons.push(actionButton('Start', '', () => update({ status: 'doing' }))); 1326 if (open) buttons.push(actionButton('Done', '', () => update({ status: 'done', note: noteText() }), '/ideas-done ' + id)); 1327 if (open || idea.lane === 'skip') buttons.push(actionButton('Drop', '', () => update({ status: 'dropped', note: noteText() }))); 1328 if (!open || idea.lane === 'doing') buttons.push(actionButton('Reopen', '', () => update({ status: 'reopen' }), 'Put the idea back in the queue (/ideas-reopen ' + id + ')')); 1329 buttons.push(actionButton('Delete', 'danger', () => { 1330 if (confirm('Delete ' + idea.key + ' for good?')) runAction('rm', { ids: [id] }); 1331 }, '/ideas-rm ' + id)); 1332 1333 const model = el('select', { class: 'model-select', 'aria-label': 'Model for the build' }, 1334 idea.model ? null : el('option', { value: '' }, 'none'), 1335 MODELS.map((m) => el('option', { value: m, selected: idea.model === m }, m))); 1336 model.addEventListener('change', () => { if (model.value) update({ model: model.value }); }); 1337 const addNote = actionButton('Add note', '', () => { 1338 const text = noteText(); 1339 if (text) update({ note: text }); 1340 else note.focus(); 1341 }); 1342 1343 return el('section', { class: 'drawer-section' }, 1344 el('h3', {}, 'Actions'), 1345 el('div', { class: 'drawer-actions' }, buttons), 1346 note, 1347 el('div', { class: 'drawer-actions' }, addNote, el('label', { class: 'model-label' }, 'Build model ', model))); 1348 } 1349 1350 async function saveIdea() { 1351 const text = $composerText.value.trim(); 1352 if (!text) { $composerText.focus(); return; } 1353 if (await runAction('add', { text }, 'Saving…')) { 1354 $composerText.value = ''; 1355 $composer.hidden = true; 1356 } 1357 } 1358 1359 /** The text of an answer, with each #12 that names an idea as a link to its ticket. */ 1360 function linkIdeas(text) { 1361 const parts = []; 1362 let last = 0; 1363 for (const m of text.matchAll(/#(\d+)/g)) { 1364 const idea = state.byId.get(Number(m[1])); 1365 if (!idea) continue; 1366 parts.push(text.slice(last, m.index), el('button', { type: 'button', class: 'link-button', onclick: () => openTicket(idea.key) }, m[0])); 1367 last = m.index + m[0].length; 1368 } 1369 parts.push(text.slice(last)); 1370 return parts; 1371 } 1372 1373 async function askQuestion() { 1374 const question = $askInput.value.trim(); 1375 if (!question || state.busy) return; 1376 setBusy(true); 1377 $askAnswer.textContent = 'Claude reads your ideas…'; 1378 try { 1379 const answer = await callApi('ask', { question }); 1380 $askAnswer.textContent = ''; 1381 $askAnswer.append(...linkIdeas(answer)); 1382 } catch (err) { 1383 $askAnswer.textContent = 'No answer: ' + err.message; 1384 } 1385 setBusy(false); 1386 } 1387 1388 function togglePanel(panel, focusTarget) { 1389 panel.hidden = !panel.hidden; 1390 if (!panel.hidden) focusTarget.focus(); 1391 } 1392 1393 /* ---- load error ---- */ 1394 1395 class HttpError extends Error { 1396 constructor(status) { super('HTTP ' + status); this.status = status; } 1397 } 1398 1399 function renderLoadError(err) { 1400 $main.textContent = ''; 1401 $counts.textContent = ''; 1402 $updated.textContent = ''; 1403 const msg = err instanceof HttpError ? 'Could not load data.json (HTTP ' + err.status + ').' : 'Could not load data.json (' + (err && err.message ? err.message : 'network error') + ').'; 1404 $main.append(el('div', { class: 'load-error' }, el('p', {}, msg))); 1405 } 1406 1407 /* ---- top-level render and hash routing ---- */ 1408 1409 function render() { 1410 renderHeader(); 1411 renderTabsActive(); 1412 renderToolbar(); 1413 if (state.query.trim() !== '') renderSearch(); 1414 else if (state.view === 'timeline') renderTimeline(); 1415 else if (state.view === 'groups') renderGroups(); 1416 else if (state.view === 'prompts') renderPrompts(); 1417 else if (state.view === 'memory') renderMemory(); 1418 else renderBoard(); 1419 renderDrawer(); 1420 // The prompt log and the memories load when a tab or a drawer needs them. 1421 const wantsPrompts = state.view === 'prompts' || String(state.openKey).startsWith('SESSION:'); 1422 if (wantsPrompts && !state.prompts.list && !state.prompts.loading) loadPrompts(); 1423 if (state.view === 'memory' && !state.memory.overview && !state.memory.loading) loadMemoryOverview(); 1424 } 1425 1426 function applyHash() { 1427 const raw = decodeURIComponent(location.hash.slice(1)); 1428 if (VIEWS.includes(raw)) { 1429 state.view = raw; 1430 state.openKey = null; 1431 } else if (/^IDEA-\d+$/i.test(raw)) { 1432 state.openKey = raw.toUpperCase(); 1433 } else if (/^(SESSION|MEM):/.test(raw)) { 1434 state.openKey = raw; 1435 } else { 1436 state.openKey = null; 1437 if (raw) state.view = 'board'; 1438 } 1439 } 1440 1441 function onHashChange() { applyHash(); render(); } 1442 1443 function onSearchInput() { 1444 const q = $searchInput.value; 1445 state.query = q; 1446 clearTimeout(searchTimer); 1447 if (q.trim() === '') { state.searchResult = null; render(); return; } 1448 searchTimer = setTimeout(() => runSearch(q), SEARCH_DEBOUNCE_MS); 1449 } 1450 1451 function bindStaticEvents() { 1452 for (const btn of $tabs.querySelectorAll('button')) { 1453 btn.addEventListener('click', () => { 1454 $searchInput.value = ''; 1455 state.query = ''; 1456 state.searchResult = null; 1457 location.hash = btn.dataset.view; 1458 }); 1459 } 1460 $parkedToggle.addEventListener('change', () => { state.showParked = $parkedToggle.checked; render(); }); 1461 $searchInput.addEventListener('input', onSearchInput); 1462 $searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { clearTimeout(searchTimer); runSearch($searchInput.value); } }); 1463 $drawerClose.addEventListener('click', closeDrawer); 1464 $drawerBackdrop.addEventListener('click', closeDrawer); 1465 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$drawer.hidden) closeDrawer(); }); 1466 document.addEventListener('click', () => { $tooltip.hidden = true; }); 1467 1468 $newIdea.addEventListener('click', () => togglePanel($composer, $composerText)); 1469 $composerSave.addEventListener('click', saveIdea); 1470 $composerCancel.addEventListener('click', () => { $composer.hidden = true; }); 1471 $composerText.addEventListener('keydown', (e) => { 1472 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveIdea(); } 1473 }); 1474 $triage.addEventListener('click', () => runAction('sort', {}, 'Claude triages the inbox…')); 1475 $buildNext.addEventListener('click', () => runAction('go', {}, 'Claude triages the new ideas, then Claude Code opens on the next one…')); 1476 $askOpen.addEventListener('click', () => togglePanel($askPanel, $askInput)); 1477 $askSend.addEventListener('click', askQuestion); 1478 $askInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') askQuestion(); }); 1479 $askClose.addEventListener('click', () => { $askPanel.hidden = true; }); 1480 $watchToggle.addEventListener('change', () => runAction('watch', { on: $watchToggle.checked })); 1481 $toast.addEventListener('click', () => { $toast.hidden = true; }); 1482 // The watcher and other sessions change the archive too. Show their changes when the page comes back. 1483 document.addEventListener('visibilitychange', () => { 1484 if (document.hidden || !isLive() || state.busy) return; 1485 // The old data stays on the page until the new data is here, so nothing flickers. 1486 if (state.prompts.list && !state.prompts.loading) loadPrompts(); 1487 if (state.memory.overview && !state.memory.loading) loadMemoryOverview({ refresh: true }); 1488 loadData().then(render, () => {}); 1489 }); 1490 } 1491 1492 async function loadData() { 1493 const res = await fetch('data.json', { cache: 'no-store' }); 1494 if (!res.ok) throw new HttpError(res.status); 1495 const data = await res.json(); 1496 state.data = data; 1497 state.byId.clear(); 1498 state.byKey.clear(); 1499 vecCache.clear(); 1500 for (const idea of data.ideas) { 1501 state.byId.set(idea.id, idea); 1502 state.byKey.set(idea.key.toUpperCase(), idea); 1503 } 1504 } 1505 1506 async function main() { 1507 bindStaticEvents(); 1508 try { 1509 await loadData(); 1510 window.addEventListener('hashchange', onHashChange); 1511 applyHash(); 1512 render(); 1513 } catch (err) { 1514 renderLoadError(err); 1515 } 1516 } 1517 1518 main(); 1519 </script> 1520 </body> 1521 </html>