Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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 &lt;question&gt;)">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>