harlay 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,22 +6,24 @@ const api = window.harlay ?? demoApi();
6
6
 
7
7
  const ICON = {
8
8
  gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>',
9
- sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>',
10
- moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>',
11
- copy: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V3.8a1.3 1.3 0 0 0-1.3-1.3H3.8a1.3 1.3 0 0 0-1.3 1.3v5.4a1.3 1.3 0 0 0 1.3 1.3h1.7"/></svg>',
12
- back: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.5 5.5 8l4.5 4.5"/></svg>',
13
- chev: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 3.5 4.5 4.5L6 12.5"/></svg>',
14
- doc: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 1.8h5l3 3v9.4H4z"/><path d="M9 1.8v3h3M6 8.5h4M6 11h4"/></svg>',
9
+ back: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.5 5.5 8l4.5 4.5"/></svg>',
10
+ plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
11
+ arrow: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>',
12
+ fresh: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8a5 5 0 1 1-1.6-3.7"/><path d="M13.2 2.5v3h-3"/></svg>',
13
+ gauge: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 11.5a6 6 0 1 1 11 0"/><path d="M8 9.5 10.8 6"/><circle cx="8" cy="10" r=".9" fill="currentColor"/></svg>',
14
+ list: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5.5 4h8M5.5 8h8M5.5 12h8"/><circle cx="2.6" cy="4" r=".6" fill="currentColor"/><circle cx="2.6" cy="8" r=".6" fill="currentColor"/><circle cx="2.6" cy="12" r=".6" fill="currentColor"/></svg>',
15
+ copy: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.6"/><path d="M10.5 3.2V3a.9.9 0 0 0-.9-.9H3.4a.9.9 0 0 0-.9.9v6.2a.9.9 0 0 0 .9.9h.3"/></svg>',
15
16
  };
16
- const TAG = { limit: 'Stopped', warning: 'Near limit', handed_off: 'Handed off' };
17
+ const TITLE = { usage: 'Usage', settings: 'Settings' };
18
+ const BADGE = { 'claude-code': 'CC', codex: 'CX', cursor: 'CU' };
19
+ const AGENT_LABEL = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' };
20
+ const HEALTH_LABEL = { fresh: 'Healthy', long: 'Getting long', struggling: 'Running low' };
17
21
 
18
22
  let model = null;
19
23
  let view = 'home';
20
24
  let openKey = null;
21
- /** The brief being previewed: { key, title, loading } or { key, title, path, text } or { key, title, error }. */
22
- let brief = null;
23
- let copied = false;
24
- const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
25
+ /** The open "add a chat by ID" form: { tool, value, busy, error }. */
26
+ let adding = null;
25
27
 
26
28
  const $ = (sel) => document.querySelector(sel);
27
29
 
@@ -38,6 +40,11 @@ function h(tag, props, ...kids) {
38
40
  return el;
39
41
  }
40
42
 
43
+ /** Replaces a section's content; empty values (null, false) are skipped, as in h(). */
44
+ function put(sel, ...kids) {
45
+ $(sel).replaceChildren(...kids.flat(Infinity).filter((k) => k != null && k !== false));
46
+ }
47
+
41
48
  function icon(name, cls) {
42
49
  const t = document.createElement('template');
43
50
  t.innerHTML = ICON[name];
@@ -46,82 +53,157 @@ function icon(name, cls) {
46
53
  return svg;
47
54
  }
48
55
 
49
- function plural(n, word) {
50
- return `${n} ${word}${n === 1 ? '' : 's'}`;
56
+ /** The agent's app icon, or a lettered badge when its app isn't installed (or in the browser demo). */
57
+ function agentIcon(tool) {
58
+ const src = model.icons?.[tool];
59
+ return src ? h('img', { class: 'agent', src, alt: '' }) : h('span', { class: 'agent badge', 'aria-hidden': 'true' }, BADGE[tool] ?? '?');
51
60
  }
52
61
 
53
- function renderHero() {
54
- const { sessions, tools } = model;
55
- const stopped = sessions.filter((s) => s.status === 'limit').length;
56
- const installed = tools.filter((t) => t.app || t.terminal).length;
57
- if (!sessions.length) {
58
- return h('div', { class: 'hero' }, h('h1', {}, 'No sessions yet.'), h('p', {}, 'Start Claude Code, Codex or Cursor and they show up here.'));
59
- }
60
- if (stopped) {
61
- return h('div', { class: 'hero' },
62
- h('h1', {}, stopped === 1 ? 'One agent ' : `${stopped} agents `, h('em', {}, 'stopped.')),
63
- h('p', {}, 'Hand it off and keep going.'));
64
- }
65
- return h('div', { class: 'hero' },
66
- h('h1', {}, 'Nothing is ', h('em', {}, 'stuck.')),
67
- h('p', {}, `${plural(sessions.length, 'recent session')} · ${plural(installed, 'agent')} ready`));
68
- }
62
+ // ---- home: chats grouped by agent ----
69
63
 
70
- function renderSession(s, showRepo) {
64
+ function renderSession(s) {
71
65
  const open = s.key === openKey;
72
- const meta = [s.toolLabel, showRepo ? s.repo : null, s.ago].filter(Boolean).join(' · ');
73
- const tool = (t) => model.tools.find((x) => x.tool === t.tool);
74
- const actions = s.busy
75
- ? h('div', { class: 'busy' }, h('span', { class: 'spin' }), 'Handing off…')
76
- : h('div', { class: 'targets' }, s.targets.map((t) =>
77
- h('button', {
78
- class: `btn${t.isDefault && t.enabled ? ' primary' : ''}`,
79
- disabled: !t.enabled,
80
- title: t.enabled ? null : `${t.label} isn't available in ${tool(t).mode === 'app' ? 'App' : 'Terminal'} mode`,
81
- onclick: () => api.act({ type: 'handoff', key: s.key, target: t.tool }),
82
- }, t.label)));
83
66
  return h('div', { class: `item${open ? ' open' : ''}` },
84
- h('button', {
85
- class: 'row',
86
- 'aria-expanded': String(open),
87
- onclick: () => { openKey = open ? null : s.key; render(); },
88
- },
89
- h('span', { class: `st ${s.status}` }),
90
- h('span', { class: 'main' }, h('span', { class: 'title' }, s.title), h('span', { class: 'meta' }, meta)),
91
- TAG[s.status] ? h('span', { class: `tag ${s.status}` }, TAG[s.status]) : null,
92
- icon('chev', 'chev')),
67
+ h('button', { class: 'row', 'aria-expanded': String(open), title: s.title, onclick: () => { openKey = open ? null : s.key; render(); } },
68
+ h('span', { class: `st ${s.status}` }, s.status === 'idle' ? null : h('i')),
69
+ h('span', { class: 'title' }, s.title),
70
+ healthBattery(s.health),
71
+ h('span', { class: 'time' }, s.ago)),
93
72
  h('div', { class: 'drawer' },
94
73
  s.status !== 'idle' ? h('p', { class: 'detail' }, s.detail) : null,
95
- s.cwd ? h('p', { class: 'path', title: s.cwd }, s.cwd) : null,
96
- h('p', { class: 'sub' }, 'Hand off to'),
97
- actions,
98
- h('button', { class: 'link', onclick: () => previewBrief(s) }, icon('doc'), 'Preview brief')));
74
+ deliveryLinks(s),
75
+ healthCard(s),
76
+ h('p', { class: 'sub' }, s.health && s.health.state !== 'fresh' ? 'Or continue in' : 'Continue in'),
77
+ s.busy
78
+ ? h('div', { class: 'busy' }, h('span', { class: 'spin' }), 'Handing off…')
79
+ : s.targets.length
80
+ ? h('div', { class: 'targets' }, s.targets.map((t) =>
81
+ h('button', { class: 'btn', onclick: () => api.act({ type: 'handoff', key: s.key, target: t.tool }) }, agentIcon(t.tool), t.label)))
82
+ : h('p', { class: 'muted-note' }, 'No other agent is installed.'),
83
+ s.pinned ? h('div', { class: 'links' }, h('button', { class: 'link quiet', onclick: () => api.act({ type: 'unpinChat', key: s.key }) }, 'Remove from list')) : null,
84
+ s.cwd ? h('p', { class: 'path', title: s.cwd }, s.cwd) : null));
99
85
  }
100
86
 
101
- function previewBrief(s) {
102
- brief = { key: s.key, title: s.title, loading: true };
103
- copied = false;
104
- view = 'brief';
105
- render();
106
- api.act({ type: 'previewBrief', key: s.key });
87
+ /** A handoff the agent hasn't read yet: send it again, or copy its prompt to paste into any chat. */
88
+ function deliveryLinks(s) {
89
+ if (!s.delivery || s.delivery === 'read') return null;
90
+ const copy = h('button', { class: 'link', onclick: () => { api.act({ type: 'copyPrompt', key: s.key }); copy.lastChild.textContent = 'Copied'; } }, icon('copy'), h('span', {}, 'Copy prompt'));
91
+ return h('div', { class: 'links' },
92
+ h('button', { class: 'link', onclick: () => api.act({ type: 'resendHandoff', key: s.key }) }, icon('arrow'), 'Open again'),
93
+ copy);
107
94
  }
108
95
 
96
+ // ---- chat health ----
97
+
109
98
  /**
110
- * Stopped and near-limit sessions first, then everything else grouped by project (most recent project first,
111
- * as the sessions arrive newest first).
99
+ * The chat's battery: one look per state, no finer than the state (the signals are risks, not a measurement): full and
100
+ * green when healthy, half and amber when getting long, low and red when struggling. The word is in the tooltip.
112
101
  */
113
- function groupSessions(sessions) {
114
- const attention = sessions.filter((s) => s.status === 'limit' || s.status === 'warning');
115
- const projects = new Map();
116
- for (const s of sessions) {
117
- if (attention.includes(s)) continue;
118
- const key = s.cwd ?? '';
119
- if (!projects.has(key)) projects.set(key, { title: s.repo ?? 'No project folder', path: s.cwd, sessions: [] });
120
- projects.get(key).sessions.push(s);
102
+ const BATTERY = { fresh: 17, long: 9, struggling: 3 };
103
+
104
+ function healthBattery(health, size = '') {
105
+ if (!health) return null;
106
+ const charge = BATTERY[health.state];
107
+ const t = document.createElement('template');
108
+ t.innerHTML = `<svg class="battery ${health.state}" viewBox="0 0 24 12" aria-hidden="true"><rect class="b-case" x=".75" y=".75" width="20.5" height="10.5" rx="2.6"/><rect class="b-nub" x="22" y="3.9" width="1.6" height="4.2" rx=".8"/><rect class="b-fill" x="2.5" y="2.5" width="${charge}" height="7" rx="1.3"/></svg>`;
109
+ return h('span', { class: `hw ${size}`, title: HEALTH_LABEL[health.state], role: 'img', 'aria-label': `Chat health: ${HEALTH_LABEL[health.state]}` }, t.content.firstElementChild);
110
+ }
111
+
112
+ /** A reason with its `command` shown as code. */
113
+ function reasonText(text) {
114
+ return text.split(/(`[^`]+`)/).map((part) => (/^`[^`]+`$/.test(part) ? h('code', {}, part.slice(1, -1)) : part));
115
+ }
116
+
117
+ /** "850", "42K", "1.2M", as the reasons write token counts. */
118
+ function formatTokens(n) {
119
+ return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${Math.round(n / 1000)}K` : String(n);
120
+ }
121
+
122
+ function healthCard(s) {
123
+ const hl = s.health;
124
+ if (!hl) return null;
125
+ const label = `New chat in ${s.agentLabel}`;
126
+ // Each number appears once: a long context's reason has the count, otherwise it gets a line of its own.
127
+ const tokens = hl.context?.tokens ?? null;
128
+ const size = tokens !== null && !hl.reasons.some((r) => r.startsWith('Long context')) ? `${formatTokens(tokens)} tokens` : null;
129
+ // A healthy chat has nothing to explain: just its size.
130
+ if (hl.state === 'fresh') return size ? h('p', { class: 'detail h-size' }, size) : null;
131
+ const lines = size ? [...hl.reasons, size] : hl.reasons;
132
+ // The state in words, then why: the specific reasons are what to weigh.
133
+ return h('div', { class: `health ${hl.state}` },
134
+ h('p', { class: 'h-state' }, HEALTH_LABEL[hl.state]),
135
+ lines.length ? h('ul', { class: 'h-why' }, lines.map((r) => h('li', {}, reasonText(r)))) : null,
136
+ s.busy
137
+ ? null
138
+ : s.canStartFresh
139
+ ? h('button', { class: 'btn', title: 'A new chat that picks up where this one left off', onclick: () => api.act({ type: 'freshStart', key: s.key }) }, icon('fresh'), label)
140
+ : h('p', { class: 'muted-note' }, `${s.agentLabel} isn’t installed in its mode, so a new chat can’t open here.`));
141
+ }
142
+
143
+ function renderAddForm(g) {
144
+ if (adding?.tool !== g.tool) return null;
145
+ const input = h('input', {
146
+ type: 'text',
147
+ value: adding.value,
148
+ placeholder: `Paste a ${g.label} chat ID`,
149
+ spellcheck: 'false',
150
+ disabled: adding.busy,
151
+ oninput: (e) => { adding.value = e.target.value; adding.error = null; },
152
+ onkeydown: (e) => {
153
+ if (e.key === 'Enter') submitAdd();
154
+ if (e.key === 'Escape') { e.stopPropagation(); adding = null; render(); }
155
+ },
156
+ });
157
+ return [
158
+ h('div', { class: 'add-form' }, input, h('button', { class: 'btn primary', disabled: adding.busy, onclick: submitAdd }, adding.busy ? 'Adding…' : 'Add')),
159
+ adding.error ? h('p', { class: 'add-error' }, adding.error) : null,
160
+ ];
161
+ }
162
+
163
+ async function submitAdd() {
164
+ if (!adding || adding.busy || !adding.value.trim()) return;
165
+ adding.busy = true;
166
+ render();
167
+ const res = await api.addChat(adding.tool, adding.value.trim()).catch(() => null);
168
+ if (res?.ok) adding = null;
169
+ else adding = { ...adding, busy: false, error: res?.error ?? 'Couldn’t add that chat.' };
170
+ render();
171
+ }
172
+
173
+ function renderGroup(g) {
174
+ const formOpen = adding?.tool === g.tool;
175
+ return h('section', { class: 'grp' },
176
+ h('div', { class: 'grp-h' },
177
+ agentIcon(g.tool),
178
+ h('span', { class: 'grp-name' }, g.label),
179
+ h('button', {
180
+ class: 'add',
181
+ 'aria-expanded': String(formOpen),
182
+ 'aria-label': `Add a ${g.label} chat by ID`,
183
+ title: `Add a ${g.label} chat by ID`,
184
+ onclick: () => { adding = formOpen ? null : { tool: g.tool, value: '', busy: false, error: null }; render(); },
185
+ }, icon('plus'))),
186
+ renderAddForm(g),
187
+ g.sessions.length ? g.sessions.map((s) => renderSession({ ...s, agentLabel: g.label })) : h('p', { class: 'none' }, 'No chats yet'));
188
+ }
189
+
190
+ function renderHome() {
191
+ const { groups } = model;
192
+ put('#home',
193
+ groups.length
194
+ ? h('div', { class: 'scroll' }, groups.map(renderGroup))
195
+ : h('p', { class: 'empty' }, 'Install Claude Code, Codex or Cursor and their chats show up here.'),
196
+ model.usage?.length ? h('button', { class: 'more', onclick: () => go('usage') }, icon('gauge', 'gauge'), 'Check agent usage', icon('arrow', 'arrow')) : null);
197
+ // Keep focus in the add-a-chat field across re-renders.
198
+ const field = $('#home .add-form input');
199
+ if (field && !adding?.busy) {
200
+ field.focus();
201
+ field.setSelectionRange(field.value.length, field.value.length);
121
202
  }
122
- return { attention, projects: [...projects.values()] };
123
203
  }
124
204
 
205
+ // ---- usage ----
206
+
125
207
  /** A bar's fill. The page's CSP forbids style attributes, so the width is set through the CSSOM. */
126
208
  function fill(pct, cls) {
127
209
  const el = h('i', { class: cls });
@@ -129,10 +211,16 @@ function fill(pct, cls) {
129
211
  return el;
130
212
  }
131
213
 
214
+ function toggleRow(label, hint, checked, action, disabled = false) {
215
+ return h('div', { class: 'set-row' },
216
+ h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
217
+ h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
218
+ }
219
+
132
220
  /** Per-agent usage: a bar per window (≈ marks Harlay's estimate), then reset time, pace and notes. */
133
221
  function renderUsage() {
134
- const rows = model.usage ?? [];
135
- if (!rows.length) return null;
222
+ const { usage, settings } = model;
223
+ const level = (pct) => (pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : '');
136
224
  const ask = model.askClaudeBridge
137
225
  ? h('div', { class: 'ask' },
138
226
  h('p', { class: 'ask-title' }, 'Show Claude’s exact usage?'),
@@ -141,301 +229,212 @@ function renderUsage() {
141
229
  h('button', { class: 'btn primary', onclick: () => api.act({ type: 'answerClaudeBridge', allow: true }) }, 'Allow'),
142
230
  h('button', { class: 'btn', onclick: () => api.act({ type: 'answerClaudeBridge', allow: false }) }, 'Not now')))
143
231
  : null;
144
- return h('div', { class: 'usage' },
145
- h('div', { class: 'label' }, 'Usage'),
232
+ const hasClaude = usage.some((r) => r.tool === 'claude-code');
233
+ put('#usage',
146
234
  ask,
147
- rows.map((r) => {
148
- const level = (pct) => (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '');
149
- const meta = [r.windows.length === 1 ? r.windows[0].resets : null, r.pace, r.note].filter(Boolean);
150
- return h('div', { class: 'u-row' },
151
- h('div', { class: 'u-name' }, r.label),
152
- h('div', { class: 'u-body' },
235
+ h('div', { class: 'u' },
236
+ usage.map((r) => {
237
+ const meta = [r.windows.length === 1 ? r.windows[0].resets : null, r.pace, r.note].filter(Boolean);
238
+ return h('div', { class: 'u-agent' },
239
+ h('div', { class: 'u-head' }, agentIcon(r.tool), h('span', { class: 'grp-name' }, r.label)),
153
240
  r.windows.map((w) => h('div', { class: 'u-win', title: w.exact ? 'Reported by the agent' : 'Harlay’s estimate from your session logs' },
154
241
  h('span', { class: 'u-lbl' }, w.label),
155
242
  h('span', { class: 'u-bar' }, fill(w.pct, level(w.pct))),
156
243
  h('span', { class: 'u-pct' }, `${w.exact ? '' : '≈'}${w.pct}%`),
157
244
  r.windows.length > 1 && w.resets ? h('span', { class: 'u-reset' }, w.resets.replace('resets ', '')) : null)),
158
245
  meta.length || r.usagePage
159
- ? h('div', { class: 'u-meta' }, meta.join(' · '),
160
- r.usagePage ? h('button', { class: 'link inline', onclick: () => api.act({ type: 'openCursorUsage' }) }, 'Open usage page') : null)
161
- : null));
162
- }));
246
+ ? h('p', { class: 'u-meta' }, meta.join(' · '),
247
+ r.usagePage ? h('button', { class: 'link', onclick: () => api.act({ type: 'openCursorUsage' }) }, 'Open usage page') : null)
248
+ : null);
249
+ })),
250
+ hasClaude && !model.askClaudeBridge
251
+ ? h('div', { class: 'set' }, toggleRow(
252
+ 'Exact Claude usage',
253
+ settings.claudeBridge === 'unavailable'
254
+ ? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
255
+ : 'Read from Claude Code’s status line in Terminal.',
256
+ settings.claudeBridge === 'on',
257
+ { type: 'toggleClaudeBridge' },
258
+ settings.claudeBridge === 'unavailable'))
259
+ : null);
163
260
  }
164
261
 
165
- function renderHome() {
166
- const { sessions } = model;
167
- if (!sessions.length) {
168
- $('#home').replaceChildren(renderHero(), renderUsage(), h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
169
- return;
170
- }
171
- const { attention, projects } = groupSessions(sessions);
172
- $('#home').replaceChildren(
173
- renderHero(),
174
- renderUsage(),
175
- h('div', { class: 'scroll' },
176
- attention.length ? [h('div', { class: 'label group attn' }, 'Needs attention'), attention.map((s) => renderSession(s, true))] : null,
177
- projects.map((p) => [
178
- h('div', { class: 'label group', title: p.path }, p.title, h('span', { class: 'count' }, String(p.sessions.length))),
179
- p.sessions.map((s) => renderSession(s, false)),
180
- ])));
181
- }
262
+ // ---- settings ----
182
263
 
183
264
  function seg(options, current, onPick) {
184
265
  return h('div', { class: 'seg', role: 'group' }, options.map((o) =>
185
- h('button', { 'aria-pressed': String(o.value === current), disabled: o.disabled, onclick: () => onPick(o.value) }, o.label)));
186
- }
187
-
188
- function toggleRow(label, hint, checked, action, disabled = false) {
189
- return h('div', { class: 'set-row' },
190
- h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
191
- h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
266
+ h('button', { 'aria-pressed': String(o.value === current), onclick: () => onPick(o.value) }, o.label)));
192
267
  }
193
268
 
194
269
  function renderSettings() {
195
- const { settings, tools } = model;
196
- $('#settings').replaceChildren(
197
- h('div', { class: 'hero' }, h('h1', {}, 'Settings'), h('p', {}, 'Where stalled work goes, and how.')),
198
- h('div', { class: 'set' },
199
- h('div', { class: 'label' }, 'Default target'),
200
- seg(tools.map((t) => ({ value: t.tool, label: t.label })), settings.defaultTarget, (v) => api.act({ type: 'setDefault', target: v })),
201
- h('div', { class: 'label' }, 'Open targets in'),
202
- tools.map((t) => h('div', { class: 'set-row' },
203
- h('span', { class: 'k' }, t.label),
204
- seg([
205
- { value: 'app', label: 'App', disabled: !t.app },
206
- { value: 'terminal', label: 'Terminal', disabled: !t.terminal },
207
- ], t.mode, (m) => api.act({ type: 'setMode', target: t.tool, mode: m })))),
208
- h('div', { class: 'label' }, 'Appearance'),
270
+ const { settings } = model;
271
+ put('#settings', h('div', { class: 'set' },
272
+ h('div', { class: 'set-row' },
273
+ h('span', { class: 'k' }, 'Appearance'),
209
274
  seg([
210
275
  { value: 'system', label: 'System' },
211
276
  { value: 'light', label: 'Light' },
212
277
  { value: 'dark', label: 'Dark' },
213
- ], settings.theme, (v) => api.act({ type: 'setTheme', theme: v })),
214
- h('div', { class: 'label' }, 'Behavior'),
215
- toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
216
- toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' }),
217
- h('div', { class: 'label' }, 'Usage'),
218
- toggleRow(
219
- 'Exact Claude usage',
220
- settings.claudeBridge === 'unavailable'
221
- ? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
222
- : 'Claude Code in Terminal passes its real usage to Harlay through its status line. Your own status line keeps working; turning this off restores it.',
223
- settings.claudeBridge === 'on',
224
- { type: 'toggleClaudeBridge' },
225
- settings.claudeBridge === 'unavailable')));
278
+ ], settings.theme, (v) => api.act({ type: 'setTheme', theme: v }))),
279
+ toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
280
+ toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' })));
226
281
  }
227
282
 
228
- /** Inline markdown: `code` and **bold**; everything else stays plain text. */
229
- function inline(text) {
230
- const out = [];
231
- text.split(/(`[^`]+`)/).forEach((part) => {
232
- if (/^`[^`]+`$/.test(part)) return out.push(h('code', {}, part.slice(1, -1)));
233
- part.split(/(\*\*[^*]+\*\*)/).forEach((p) => {
234
- if (/^\*\*[^*]+\*\*$/.test(p)) out.push(h('strong', {}, p.slice(2, -2)));
235
- else if (p) out.push(p.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'));
236
- });
237
- });
238
- return out;
283
+ function findSession(key) {
284
+ for (const g of model.groups) for (const s of g.sessions) if (s.key === key) return s;
285
+ return null;
239
286
  }
240
287
 
241
- /** Just enough markdown for HANDOFF.md: headings, quotes, lists, checkboxes, code blocks and paragraphs. */
242
- function markdown(src) {
243
- let text = src.replace(/\r\n/g, '\n');
244
- if (text.startsWith('---\n')) {
245
- const end = text.indexOf('\n---\n', 4);
246
- if (end !== -1) text = text.slice(end + 5);
247
- }
248
- const lines = text.split('\n');
249
- const out = [];
250
- let list = null;
251
- let quote = null;
252
- let para = [];
253
- const flush = () => {
254
- if (para.length) out.push(h('p', {}, inline(para.join(' '))));
255
- para = [];
256
- list = null;
257
- quote = null;
258
- };
259
- for (let i = 0; i < lines.length; i++) {
260
- const line = lines[i];
261
- let m;
262
- if (line.startsWith('```')) {
263
- flush();
264
- const code = [];
265
- while (++i < lines.length && !lines[i].startsWith('```')) code.push(lines[i]);
266
- out.push(h('pre', {}, code.join('\n')));
267
- } else if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) {
268
- flush();
269
- out.push(h(m[1].length === 1 ? 'h3' : 'h4', {}, inline(m[2])));
270
- } else if ((m = /^>\s?(.*)$/.exec(line))) {
271
- if (para.length || list) flush();
272
- if (!quote) out.push((quote = h('blockquote', {})));
273
- if (m[1]) quote.append(h('p', {}, inline(m[1])));
274
- } else if ((m = /^\s*(?:[-*]|\d+\.)\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(line))) {
275
- if (para.length || quote) flush();
276
- if (!list) out.push((list = h('ul', {})));
277
- const box = m[1] === undefined ? null : h('span', { class: `box${m[1] === ' ' ? '' : ' done'}` });
278
- list.append(h('li', {}, box, inline(m[2])));
279
- } else if (line.trim() === '') {
280
- flush();
281
- } else if (line.startsWith('|')) {
282
- flush();
283
- out.push(h('pre', {}, line));
284
- } else {
285
- if (list || quote) flush();
286
- para.push(line.trim());
287
- }
288
- }
289
- flush();
290
- return out;
288
+ function findTool(key) {
289
+ for (const g of model.groups) if (g.sessions.some((s) => s.key === key)) return g.tool;
290
+ return null;
291
291
  }
292
292
 
293
- /** Headings for the blocks of an inline handoff prompt (see packages/core/src/brief/prompt.ts). */
294
- const BRIEF_BLOCKS = {
295
- original_goal: 'Original goal',
296
- rules_from_user: 'Rules from the user',
297
- recent_user_messages: 'Recent messages (oldest first)',
298
- where_it_stopped: 'Where it stopped',
299
- files: 'Files',
300
- last_failure: 'Last failure',
301
- };
293
+ // ---- router (⌥H): the latest chat, branching to the agents it can continue in ----
302
294
 
303
- /** `key="value"` pairs of the opening `<handoff …>` line (the prompt never puts `"` inside a value). */
304
- function tagAttrs(line) {
305
- const out = {};
306
- for (const m of line.matchAll(/([a-z_]+)="([^"]*)"/g)) out[m[1]] = m[2];
307
- return out;
295
+ /** Usage of an agent's fullest window, for the thin meter under its icon. */
296
+ function usageOf(tool) {
297
+ const row = model.usage?.find((u) => u.tool === tool);
298
+ if (!row || !row.windows.length) return null;
299
+ return Math.max(...row.windows.map((w) => w.pct));
308
300
  }
309
301
 
310
- /**
311
- * HANDOFF.md as Harlay writes it now: the tagged launch prompt. Block text is shown as written, never parsed as
312
- * Markdown, so a line starting with ``` is plain text; the failure output is one verbatim block.
313
- */
314
- function handoffBrief(src) {
315
- const lines = src.replace(/\r\n/g, '\n').trim().split('\n');
316
- const out = [];
317
- let block = null;
318
- let list = null;
319
- let pre = null;
320
- const close = () => {
321
- if (pre) out.push(h('pre', {}, pre.join('\n')));
322
- block = list = pre = null;
323
- };
324
- for (const line of lines) {
325
- const t = line.trim();
326
- let m;
327
- if (!block && /^<handoff\s/.test(t)) {
328
- const a = tagAttrs(t);
329
- out.push(h('h3', {}, `Handoff from ${a.from || 'another agent'}${a.task ? ` · ${a.task}` : ''}`));
330
- } else if (!block && (m = /^<([a-z_]+)(?:\s[^>]*)?>$/.exec(t)) && Object.hasOwn(BRIEF_BLOCKS, m[1])) {
331
- block = m[1];
332
- out.push(h('h4', {}, BRIEF_BLOCKS[block]));
333
- if (block === 'last_failure') pre = [];
334
- } else if ((m = /^<\/([a-z_]+)>$/.exec(t)) && (m[1] === block || (!block && m[1] === 'handoff'))) {
335
- close();
336
- } else if (pre) {
337
- pre.push(line);
338
- } else if (t === '') {
339
- list = null;
340
- } else if (!block) {
341
- out.push(h('p', { class: 'muted' }, t));
342
- } else if (t.startsWith('- ')) {
343
- if (!list) out.push((list = h('ul', {})));
344
- list.append(h('li', {}, t.slice(2)));
345
- } else {
346
- list = null;
347
- out.push(h('p', {}, t));
348
- }
302
+ const SVG_NS = 'http://www.w3.org/2000/svg';
303
+
304
+ /** Curves from the chat down to each tile; the selected one is drawn in the accent color and flows. */
305
+ function routeLines(n, selected) {
306
+ const W = 320, H = 46, x0 = W / 2;
307
+ const svg = document.createElementNS(SVG_NS, 'svg');
308
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
309
+ svg.setAttribute('class', 'r-lines');
310
+ svg.setAttribute('aria-hidden', 'true');
311
+ for (let i = 0; i < n; i++) {
312
+ const x = ((i + 0.5) * W) / n;
313
+ const p = document.createElementNS(SVG_NS, 'path');
314
+ p.setAttribute('d', `M${x0} 0 C ${x0} ${H * 0.55}, ${x} ${H * 0.45}, ${x} ${H}`);
315
+ p.setAttribute('class', i === selected ? 'on' : '');
316
+ svg.append(p);
349
317
  }
350
- close();
318
+ const dot = document.createElementNS(SVG_NS, 'circle');
319
+ dot.setAttribute('cx', String(x0));
320
+ dot.setAttribute('cy', '2');
321
+ dot.setAttribute('r', '2.5');
322
+ svg.append(dot);
323
+ return svg;
324
+ }
325
+
326
+ /** The router's choices: other agents (default first), then a new chat in the same agent. */
327
+ function routeChoices(s, tool) {
328
+ const out = s.targets.map((t) => ({ tool: t.tool, label: t.label, mode: t.mode, act: { type: 'handoff', key: s.key, target: t.tool } }));
329
+ // Not when this agent just hit its limit: a new chat with it would stop too.
330
+ if (s.canStartFresh && s.status !== 'limit') out.push({ tool, label: 'New chat', mode: null, fresh: true, act: { type: 'freshStart', key: s.key } });
351
331
  return out;
352
332
  }
353
333
 
354
- function renderBrief() {
355
- const session = model.sessions.find((s) => s.key === brief.key);
356
- let body;
357
- if (brief.loading) body = h('div', { class: 'busy pad' }, h('span', { class: 'spin' }), 'Building the brief…');
358
- else if (brief.error) body = h('p', { class: 'empty' }, `Couldn’t build the brief: ${brief.error}`);
359
- else body = h('div', { class: 'md' }, brief.text.trim().startsWith('<handoff ') ? handoffBrief(brief.text) : markdown(brief.text));
360
- const ready = !brief.loading && !brief.error;
361
- $('#brief').replaceChildren(
362
- h('div', { class: 'hero' }, h('h1', {}, 'Brief'), h('p', { class: 'one-line' }, brief.title)),
363
- h('div', { class: 'scroll brief-scroll' }, body),
364
- h('div', { class: 'brief-bar' },
365
- h('button', {
366
- class: 'btn',
367
- disabled: !ready,
368
- onclick: () => {
369
- api.act({ type: 'copyBrief', key: brief.key });
370
- copied = true;
371
- render();
372
- },
373
- }, icon('copy'), copied ? 'Copied' : 'Copy'),
374
- h('button', { class: 'btn', disabled: !ready, title: brief.path ?? null, onclick: () => api.act({ type: 'openBrief', key: brief.key }) }, icon('doc'), 'Open file'),
375
- session && !session.busy
376
- ? session.targets.filter((t) => t.enabled).slice(0, 1).map((t) =>
377
- h('button', { class: 'btn primary push', disabled: !ready, onclick: () => api.act({ type: 'handoff', key: session.key, target: t.tool }) }, `Hand off to ${t.label}`))
378
- : null));
334
+ let routeKey = null;
335
+ let routeSel = 0;
336
+
337
+ function renderRoute() {
338
+ const s = routeKey ? findSession(routeKey) : null;
339
+ if (!s) return go('home');
340
+ const tool = findTool(s.key);
341
+ const choices = routeChoices(s, tool);
342
+ routeSel = Math.min(routeSel, Math.max(0, choices.length - 1));
343
+ const tile = (c, i) => {
344
+ const pct = c.fresh ? null : usageOf(c.tool);
345
+ return h('button', {
346
+ class: `tile${i === routeSel ? ' sel' : ''}`,
347
+ title: c.fresh ? 'New chat that picks up where this one left off' : `Continue in ${c.label}${c.mode === 'terminal' ? ' (Terminal)' : ''}`,
348
+ onmouseenter: () => { if (routeSel !== i) { routeSel = i; render(); } },
349
+ onclick: () => api.act(c.act),
350
+ },
351
+ h('span', { class: 'tile-ic' },
352
+ agentIcon(c.tool),
353
+ c.fresh ? h('span', { class: 'corner' }, icon('fresh')) : c.mode === 'terminal' ? h('span', { class: 'corner mono' }, '>_') : null),
354
+ h('span', { class: 'tile-name' }, c.label),
355
+ pct === null ? h('span', { class: 'tile-meter' }) : h('span', { class: 'tile-meter on', title: `${pct}% of its limit used` }, fill(pct, pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : '')),
356
+ h('kbd', { class: 'tile-key' }, String(i + 1)));
357
+ };
358
+ put('#route',
359
+ // Opened by itself because this chat hit its limit: say so, and when it resets.
360
+ s.status === 'limit' ? h('p', { class: 'r-alert' }, `${AGENT_LABEL[tool]} stopped · ${s.detail}. Continue it in:`) : null,
361
+ h('div', { class: 'r-src' },
362
+ h('span', { class: 'r-src-ic' }, agentIcon(tool), h('span', { class: `st ${s.status}` }, s.status === 'idle' ? null : h('i'))),
363
+ h('span', { class: 'r-src-title', title: s.title }, s.title),
364
+ h('span', { class: 'r-src-time' }, s.ago)),
365
+ s.busy
366
+ ? h('div', { class: 'busy r-busy' }, h('span', { class: 'spin' }))
367
+ : choices.length
368
+ ? [routeLines(choices.length, routeSel), h('div', { class: `r-tiles n${choices.length}` }, choices.map(tile))]
369
+ : h('p', { class: 'none' }, 'No other agent installed'),
370
+ h('div', { class: 'r-foot' },
371
+ h('span', { class: 'r-hint' }, h('kbd', {}, '↵'), h('kbd', {}, '1–' + Math.max(1, choices.length))),
372
+ h('span', { class: 'r-acts' },
373
+ h('button', { class: 'link r-dismiss', onclick: () => api.hide() }, 'Not now'),
374
+ h('button', { class: 'link r-all', onclick: () => go('home') }, icon('list'), 'All chats'))));
379
375
  }
380
376
 
377
+ // ---- frame ----
378
+
381
379
  function render() {
382
380
  if (!model) return;
383
- $('#home').hidden = view !== 'home';
384
- $('#settings').hidden = view !== 'settings';
385
- $('#brief').hidden = view !== 'brief';
386
- const btn = $('#toggle-settings');
387
- btn.replaceChildren(icon(view === 'home' ? 'gear' : 'back'));
388
- btn.setAttribute('aria-label', view === 'home' ? 'Settings' : 'Back');
389
- btn.title = btn.getAttribute('aria-label');
390
- const dark = darkQuery.matches;
391
- const theme = $('#toggle-theme');
392
- theme.replaceChildren(icon(dark ? 'sun' : 'moon'));
393
- theme.setAttribute('aria-label', dark ? 'Light mode' : 'Dark mode');
394
- theme.title = theme.getAttribute('aria-label');
395
- const pill = $('#pill');
396
- pill.classList.toggle('auto', model.settings.autoContinue);
397
- $('#pill-text').textContent = model.settings.autoContinue ? 'Auto-continue' : 'Standing by';
381
+ for (const v of ['home', 'route', 'usage', 'settings']) $(`#${v}`).hidden = view !== v;
382
+ const home = view === 'home' || view === 'route';
383
+ $('#brand').hidden = !home;
384
+ $('#open-settings').hidden = !home;
385
+ // The router (⌥H) goes back to all chats too.
386
+ $('#back').hidden = view === 'home';
387
+ $('#back').title = view === 'route' ? 'All chats' : 'Back';
388
+ $('#view-title').hidden = home;
389
+ $('#view-title').textContent = TITLE[view] ?? '';
398
390
  $('#hotkey').textContent = model.hotkey;
399
391
  if (view === 'home') renderHome();
400
- else if (view === 'settings') renderSettings();
401
- else renderBrief();
392
+ else if (view === 'route') renderRoute();
393
+ else if (view === 'usage') renderUsage();
394
+ else renderSettings();
402
395
  }
403
396
 
404
- function show() {
405
- view = 'home';
406
- brief = null;
407
- openKey = null;
397
+ function go(next) {
398
+ view = next;
408
399
  render();
400
+ }
401
+
402
+ function show(opts) {
403
+ openKey = null;
404
+ adding = null;
405
+ routeKey = opts?.route ?? null;
406
+ routeSel = 0;
407
+ go(routeKey && findSession(routeKey) ? 'route' : 'home');
409
408
  const panel = $('#panel');
409
+ // Otherwise the window gives focus back to the last button clicked (often the gear), with a keyboard focus ring.
410
+ panel.focus({ preventScroll: true });
410
411
  panel.classList.remove('in');
411
412
  void panel.offsetWidth;
412
413
  panel.classList.add('in');
413
414
  }
414
415
 
415
- function goHome() {
416
- view = 'home';
417
- brief = null;
418
- render();
419
- }
420
-
421
- $('#toggle-settings').addEventListener('click', () => {
422
- if (view === 'home') {
423
- view = 'settings';
424
- render();
425
- } else {
426
- goHome();
427
- }
428
- });
429
- $('#toggle-theme').addEventListener('click', () => api.act({ type: 'setTheme', theme: darkQuery.matches ? 'light' : 'dark' }));
430
- darkQuery.addEventListener('change', render);
416
+ $('#back').append(icon('back'));
417
+ $('#open-settings').append(icon('gear'));
418
+ $('#back').addEventListener('click', () => go('home'));
419
+ $('#open-settings').addEventListener('click', () => go('settings'));
431
420
  $('#quit').addEventListener('click', () => api.act({ type: 'quit' }));
432
421
  document.addEventListener('keydown', (e) => {
433
- if (e.key !== 'Escape') return;
434
- if (view !== 'home') {
435
- goHome();
436
- } else {
437
- api.hide();
422
+ if (view === 'route' && !e.metaKey && !e.ctrlKey && !e.altKey) {
423
+ const s = routeKey ? findSession(routeKey) : null;
424
+ const choices = s && !s.busy ? routeChoices(s, findTool(s.key)) : [];
425
+ const n = Number(e.key);
426
+ if (Number.isInteger(n) && n >= 1 && n <= choices.length) return api.act(choices[n - 1].act);
427
+ if (e.key === 'Enter' && choices[routeSel]) return api.act(choices[routeSel].act);
428
+ if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && choices.length) {
429
+ routeSel = (routeSel + (e.key === 'ArrowRight' ? 1 : choices.length - 1)) % choices.length;
430
+ return render();
431
+ }
438
432
  }
433
+ if (e.key !== 'Escape') return;
434
+ if (view === 'route') api.hide();
435
+ else if (view !== 'home') go('home');
436
+ else if (adding) { adding = null; render(); }
437
+ else api.hide();
439
438
  });
440
439
 
441
440
  // Size the window to the panel: its height plus the wrap's padding (the room the shadow needs).
@@ -444,121 +443,87 @@ new ResizeObserver(() => api.resize(Math.ceil(wrap.getBoundingClientRect().heigh
444
443
 
445
444
  api.onModel((m) => {
446
445
  model = m;
447
- if (openKey && !model.sessions.some((s) => s.key === openKey)) openKey = null;
446
+ if (openKey && !findSession(openKey)) openKey = null;
448
447
  render();
449
448
  });
450
449
  api.onShown(show);
451
- api.onBrief((b) => {
452
- // Ignore a late reply for a brief the user already navigated away from.
453
- if (b.loading || !brief || brief.key === b.key) {
454
- brief = b;
455
- view = 'brief';
456
- render();
457
- }
458
- });
459
450
  api.ready();
460
451
 
461
452
  function demoApi() {
462
- const tools = [
463
- { tool: 'claude-code', label: 'Claude Code', mode: 'app', app: true, terminal: false },
464
- { tool: 'codex', label: 'Codex', mode: 'app', app: true, terminal: true },
465
- { tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
466
- ];
467
- const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
453
+ const label = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' };
454
+ const settings = { autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
455
+ let asked = false;
468
456
  const usage = [
469
457
  { tool: 'claude-code', label: 'Claude Code', windows: [{ label: '5h', pct: 72, exact: false, resets: 'resets 4:10 PM' }], pace: '≈ 38 min to limit at this pace', note: '≈ estimated from your session logs', usagePage: false },
470
458
  { tool: 'codex', label: 'Codex', windows: [{ label: '5h', pct: 18, exact: true, resets: 'resets in 52m' }, { label: 'Week', pct: 41, exact: true, resets: 'resets Fri 7:12 PM' }], pace: null, note: '1 free limit reset available', usagePage: false },
471
459
  { tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
472
460
  ];
473
- const targets = (from) => {
474
- const order = [settings.defaultTarget, ...tools.map((t) => t.tool).filter((t) => t !== settings.defaultTarget)];
475
- return order.filter((t) => t !== from).map((t) => {
476
- const info = tools.find((x) => x.tool === t);
477
- return { tool: t, label: info.label, enabled: info[info.mode], isDefault: t === settings.defaultTarget };
478
- });
461
+ const fresh = { state: 'fresh', reasons: [], context: { tokens: 42_000, window: null, compactions: 0 } };
462
+ const long = { state: 'long', reasons: ['Long context: 197K tokens'], context: { tokens: 197_000, window: null, compactions: 0 } };
463
+ const longer = { state: 'long', reasons: ['Long context: 527K tokens'], context: { tokens: 527_000, window: null, compactions: 0 } };
464
+ const struggling = {
465
+ state: 'struggling',
466
+ reasons: ['`npm test -- auth` failed 4 times in a row', 'You pushed back 2 times in your last 6 messages', 'Compacted 2 times, so early details are summaries'],
467
+ context: { tokens: 188_000, window: 258_400, compactions: 2 },
479
468
  };
480
- const sessions = [
481
- { key: 'claude-code:1', from: 'claude-code', title: 'Migrate auth to JWT', toolLabel: 'Claude Code', status: 'limit', detail: 'Limit hit · resets 6:00 PM', cwd: '/Users/you/acme-api', repo: 'acme-api', ago: 'now', busy: false },
482
- { key: 'codex:2', from: 'codex', title: 'Fix flaky checkout tests', toolLabel: 'Codex', status: 'warning', detail: '92% of limit used', cwd: '/Users/you/shop', repo: 'shop', ago: '4m ago', busy: false },
483
- { key: 'claude-code:3', from: 'claude-code', title: 'Harlay landing page', toolLabel: 'Claude Code', status: 'idle', detail: 'Idle', cwd: '/Users/you/harlay', repo: 'harlay', ago: '1h ago', busy: false },
484
- { key: 'codex:5', from: 'codex', title: 'Rate limiter for /login', toolLabel: 'Codex', status: 'idle', detail: 'Idle', cwd: '/Users/you/acme-api', repo: 'acme-api', ago: '2h ago', busy: false },
485
- { key: 'cursor:4', from: 'cursor', title: 'Onboarding copy pass', toolLabel: 'Cursor', status: 'handed_off', detail: 'Handed off to Codex', cwd: '/Users/you/web', repo: 'web', ago: '3h ago', busy: false },
469
+ const chat = (tool, id, title, ago, health, status = 'idle', detail = 'Idle', pinned = false) =>
470
+ ({ key: `${tool}:${id}`, tool, title, ago, status, detail, cwd: `/Users/you/${title.split(' ')[0].toLowerCase()}`, busy: false, pinned, health, canStartFresh: true, delivery: null });
471
+ const chats = [
472
+ chat('claude-code', '1', 'Migrate auth to JWT', 'now', struggling, 'limit', 'Limit hit · resets 3:00 PM'),
473
+ chat('claude-code', '2', 'Harlay landing page', '1h', long),
474
+ chat('claude-code', '3', 'Software architecture diagram', '3h', fresh),
475
+ chat('codex', '4', 'Fix flaky checkout tests', '4m', longer, 'warning', '92% of limit used'),
476
+ chat('codex', '5', 'Rate limiter for /login', '2h', fresh),
477
+ chat('codex', '6', 'Implement the task in Colab', '1d', fresh),
478
+ { ...chat('cursor', '7', 'Onboarding copy pass', '3h', null, 'handed_off', "Handed off to Codex · Codex hasn't picked it up"), delivery: 'missed' },
486
479
  ];
487
480
  let onModel = () => {};
488
- let onBrief = () => {};
489
- const sampleBrief = (title) => [
490
- `<handoff from="Claude Code" reason="usage_limit" task="${title.replace(/"/g, "'")}">`,
491
- "You are taking over a coding session that Claude Code could not finish (it hit a usage limit). What you need to start is below; don't re-read the repo to rebuild it.",
492
- '',
493
- '<rules_from_user>',
494
- "- Don't change the public /v1 API shape.",
495
- '- Never push to main.',
496
- '</rules_from_user>',
497
- '',
498
- '<recent_user_messages oldest_first="true">',
499
- '- Move auth from session cookies to JWT across api/.',
500
- '- Also migrate billing.ts before you run the tests.',
501
- '</recent_user_messages>',
502
- '',
503
- '<where_it_stopped>',
504
- '```bash npm test -- auth ``` still fails on the refresh test. Next I will migrate the last 3 route handlers in api/routes/.',
505
- 'Open todos: [in progress] Migrate billing.ts; [pending] Delete the legacy session store',
506
- '</where_it_stopped>',
507
- '',
508
- '<files>',
509
- 'Touched: src/lib/jwt.ts, api/routes/auth.ts',
510
- 'Uncommitted: src/lib/jwt.ts (added, +42 -0), api/routes/auth.ts (modified, +12 -30)',
511
- '</files>',
512
- '',
513
- '<last_failure>',
514
- '$ npm test -- auth (exit 1)',
515
- 'FAIL auth.test.ts > refresh token rotates',
516
- ' expected 401, received 200',
517
- '</last_failure>',
518
- '',
519
- "Only if you need more: .harlay/handoffs/1005-1742-migrate-auth-to-jwt/transcript.md (search it, don't read it all) and .harlay/handoffs/1005-1742-migrate-auth-to-jwt/diff.patch.",
520
- '</handoff>',
521
- '',
522
- 'Continue from the most recent user message. Start with the next concrete step.',
523
- '',
524
- ].join('\n');
525
481
  const emit = () => onModel({
526
- sessions: sessions.map((s) => ({ ...s, targets: targets(s.from) })),
482
+ groups: ['claude-code', 'codex', 'cursor'].map((tool) => ({
483
+ tool,
484
+ label: label[tool],
485
+ sessions: chats.filter((c) => c.tool === tool).map((c) => ({
486
+ ...c,
487
+ targets: ['codex', 'claude-code', 'cursor'].filter((t) => t !== tool).map((t) => ({ tool: t, label: label[t], mode: t === 'cursor' ? 'terminal' : 'app' })),
488
+ })),
489
+ })),
527
490
  usage,
528
- askClaudeBridge: !settings.asked,
491
+ icons: {},
492
+ askClaudeBridge: !asked,
529
493
  settings: { ...settings },
530
- tools: tools.map((t) => ({ ...t })),
531
494
  hotkey: '⌥H',
532
495
  });
533
496
  return {
534
497
  onModel: (cb) => { onModel = cb; },
535
498
  onShown: (cb) => setTimeout(cb, 50),
536
- onBrief: (cb) => { onBrief = cb; },
537
499
  ready: emit,
538
500
  resize: () => {},
539
501
  hide: () => console.log('[demo] hide'),
502
+ addChat: async (tool, id) => {
503
+ await new Promise((r) => setTimeout(r, 500));
504
+ if (id.length < 4) return { ok: false, error: 'That doesn’t look like a chat ID.' };
505
+ chats.push(chat(tool, id, `Chat ${id.slice(0, 8)}`, '5d', fresh, 'idle', 'Idle', true));
506
+ emit();
507
+ return { ok: true };
508
+ },
540
509
  act: (a) => {
541
510
  console.log('[demo] action', a);
542
- if (a.type === 'setDefault') settings.defaultTarget = a.target;
543
- if (a.type === 'setMode') tools.find((t) => t.tool === a.target).mode = a.mode;
544
511
  if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
545
512
  if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
546
513
  if (a.type === 'setTheme') settings.theme = a.theme;
547
514
  if (a.type === 'answerClaudeBridge') {
548
- settings.asked = true;
515
+ asked = true;
549
516
  settings.claudeBridge = a.allow ? 'on' : 'off';
550
517
  }
551
518
  if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
552
- if (a.type === 'previewBrief') {
553
- const s = sessions.find((x) => x.key === a.key);
554
- setTimeout(() => onBrief({ key: s.key, title: s.title, path: `${s.cwd}/.harlay/HANDOFF.md`, text: sampleBrief(s.title) }), 600);
555
- return;
556
- }
557
- if (a.type === 'handoff') {
558
- const s = sessions.find((x) => x.key === a.key);
559
- s.busy = true;
519
+ if (a.type === 'unpinChat') chats.splice(chats.findIndex((c) => c.key === a.key), 1);
520
+ if (a.type === 'handoff' || a.type === 'freshStart') {
521
+ const c = chats.find((x) => x.key === a.key);
522
+ c.busy = true;
560
523
  emit();
561
- setTimeout(() => { s.busy = false; s.status = 'handed_off'; s.detail = `Handed off to ${tools.find((t) => t.tool === a.target).label}`; emit(); }, 1200);
524
+ const to = label[a.target ?? c.tool];
525
+ setTimeout(() => { c.busy = false; c.status = 'handed_off'; c.delivery = 'waiting'; c.detail = `Handed off to ${to} · press Enter in ${to} to send it`; c.health = fresh; emit(); }, 1200);
526
+ setTimeout(() => { c.delivery = 'read'; c.detail = `Handed off to ${to} · ${to} read it`; emit(); }, 4000);
562
527
  return;
563
528
  }
564
529
  emit();