harlay 0.1.2 → 0.2.0

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,240 +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
+ 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
+ }
292
+
293
+ // ---- router (⌥H): the latest chat, branching to the agents it can continue in ----
294
+
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));
300
+ }
301
+
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);
288
317
  }
289
- flush();
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 } });
290
331
  return out;
291
332
  }
292
333
 
293
- function renderBrief() {
294
- const session = model.sessions.find((s) => s.key === brief.key);
295
- let body;
296
- if (brief.loading) body = h('div', { class: 'busy pad' }, h('span', { class: 'spin' }), 'Building the brief…');
297
- else if (brief.error) body = h('p', { class: 'empty' }, `Couldn’t build the brief: ${brief.error}`);
298
- else body = h('div', { class: 'md' }, markdown(brief.text));
299
- const ready = !brief.loading && !brief.error;
300
- $('#brief').replaceChildren(
301
- h('div', { class: 'hero' }, h('h1', {}, 'Brief'), h('p', { class: 'one-line' }, brief.title)),
302
- h('div', { class: 'scroll brief-scroll' }, body),
303
- h('div', { class: 'brief-bar' },
304
- h('button', {
305
- class: 'btn',
306
- disabled: !ready,
307
- onclick: () => {
308
- api.act({ type: 'copyBrief', key: brief.key });
309
- copied = true;
310
- render();
311
- },
312
- }, icon('copy'), copied ? 'Copied' : 'Copy'),
313
- h('button', { class: 'btn', disabled: !ready, title: brief.path ?? null, onclick: () => api.act({ type: 'openBrief', key: brief.key }) }, icon('doc'), 'Open file'),
314
- session && !session.busy
315
- ? session.targets.filter((t) => t.enabled).slice(0, 1).map((t) =>
316
- h('button', { class: 'btn primary push', disabled: !ready, onclick: () => api.act({ type: 'handoff', key: session.key, target: t.tool }) }, `Hand off to ${t.label}`))
317
- : 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'))));
318
375
  }
319
376
 
377
+ // ---- frame ----
378
+
320
379
  function render() {
321
380
  if (!model) return;
322
- $('#home').hidden = view !== 'home';
323
- $('#settings').hidden = view !== 'settings';
324
- $('#brief').hidden = view !== 'brief';
325
- const btn = $('#toggle-settings');
326
- btn.replaceChildren(icon(view === 'home' ? 'gear' : 'back'));
327
- btn.setAttribute('aria-label', view === 'home' ? 'Settings' : 'Back');
328
- btn.title = btn.getAttribute('aria-label');
329
- const dark = darkQuery.matches;
330
- const theme = $('#toggle-theme');
331
- theme.replaceChildren(icon(dark ? 'sun' : 'moon'));
332
- theme.setAttribute('aria-label', dark ? 'Light mode' : 'Dark mode');
333
- theme.title = theme.getAttribute('aria-label');
334
- const pill = $('#pill');
335
- pill.classList.toggle('auto', model.settings.autoContinue);
336
- $('#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] ?? '';
337
390
  $('#hotkey').textContent = model.hotkey;
338
391
  if (view === 'home') renderHome();
339
- else if (view === 'settings') renderSettings();
340
- else renderBrief();
392
+ else if (view === 'route') renderRoute();
393
+ else if (view === 'usage') renderUsage();
394
+ else renderSettings();
341
395
  }
342
396
 
343
- function show() {
344
- view = 'home';
345
- brief = null;
346
- openKey = null;
397
+ function go(next) {
398
+ view = next;
347
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');
348
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 });
349
411
  panel.classList.remove('in');
350
412
  void panel.offsetWidth;
351
413
  panel.classList.add('in');
352
414
  }
353
415
 
354
- function goHome() {
355
- view = 'home';
356
- brief = null;
357
- render();
358
- }
359
-
360
- $('#toggle-settings').addEventListener('click', () => {
361
- if (view === 'home') {
362
- view = 'settings';
363
- render();
364
- } else {
365
- goHome();
366
- }
367
- });
368
- $('#toggle-theme').addEventListener('click', () => api.act({ type: 'setTheme', theme: darkQuery.matches ? 'light' : 'dark' }));
369
- 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'));
370
420
  $('#quit').addEventListener('click', () => api.act({ type: 'quit' }));
371
421
  document.addEventListener('keydown', (e) => {
372
- if (e.key !== 'Escape') return;
373
- if (view !== 'home') {
374
- goHome();
375
- } else {
376
- 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
+ }
377
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();
378
438
  });
379
439
 
380
440
  // Size the window to the panel: its height plus the wrap's padding (the room the shadow needs).
@@ -383,86 +443,87 @@ new ResizeObserver(() => api.resize(Math.ceil(wrap.getBoundingClientRect().heigh
383
443
 
384
444
  api.onModel((m) => {
385
445
  model = m;
386
- if (openKey && !model.sessions.some((s) => s.key === openKey)) openKey = null;
446
+ if (openKey && !findSession(openKey)) openKey = null;
387
447
  render();
388
448
  });
389
449
  api.onShown(show);
390
- api.onBrief((b) => {
391
- // Ignore a late reply for a brief the user already navigated away from.
392
- if (b.loading || !brief || brief.key === b.key) {
393
- brief = b;
394
- view = 'brief';
395
- render();
396
- }
397
- });
398
450
  api.ready();
399
451
 
400
452
  function demoApi() {
401
- const tools = [
402
- { tool: 'claude-code', label: 'Claude Code', mode: 'app', app: true, terminal: false },
403
- { tool: 'codex', label: 'Codex', mode: 'app', app: true, terminal: true },
404
- { tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
405
- ];
406
- 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;
407
456
  const usage = [
408
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 },
409
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 },
410
459
  { tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
411
460
  ];
412
- const targets = (from) => {
413
- const order = [settings.defaultTarget, ...tools.map((t) => t.tool).filter((t) => t !== settings.defaultTarget)];
414
- return order.filter((t) => t !== from).map((t) => {
415
- const info = tools.find((x) => x.tool === t);
416
- return { tool: t, label: info.label, enabled: info[info.mode], isDefault: t === settings.defaultTarget };
417
- });
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 },
418
468
  };
419
- const sessions = [
420
- { 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 },
421
- { 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 },
422
- { 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 },
423
- { 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 },
424
- { 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' },
425
479
  ];
426
480
  let onModel = () => {};
427
- let onBrief = () => {};
428
- const sampleBrief = (title) => `---\nharlay: 1\n---\n\n# Continue: ${title}\n\n> You are continuing a coding task that another AI agent started.\n\n## 1. Goal (user's words)\n> Move auth from session cookies to JWT across \`api/\`.\n\n## 2. Next step\nMigrate the last 3 route handlers in \`api/routes/\`.\n\n## 4. Progress\n- [x] Added \`src/lib/jwt.ts\`\n- [ ] Migrate \`billing.ts\`\n\n\`\`\`text\nnpm test -- auth\n\`\`\`\n`;
429
481
  const emit = () => onModel({
430
- 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
+ })),
431
490
  usage,
432
- askClaudeBridge: !settings.asked,
491
+ icons: {},
492
+ askClaudeBridge: !asked,
433
493
  settings: { ...settings },
434
- tools: tools.map((t) => ({ ...t })),
435
494
  hotkey: '⌥H',
436
495
  });
437
496
  return {
438
497
  onModel: (cb) => { onModel = cb; },
439
498
  onShown: (cb) => setTimeout(cb, 50),
440
- onBrief: (cb) => { onBrief = cb; },
441
499
  ready: emit,
442
500
  resize: () => {},
443
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
+ },
444
509
  act: (a) => {
445
510
  console.log('[demo] action', a);
446
- if (a.type === 'setDefault') settings.defaultTarget = a.target;
447
- if (a.type === 'setMode') tools.find((t) => t.tool === a.target).mode = a.mode;
448
511
  if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
449
512
  if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
450
513
  if (a.type === 'setTheme') settings.theme = a.theme;
451
514
  if (a.type === 'answerClaudeBridge') {
452
- settings.asked = true;
515
+ asked = true;
453
516
  settings.claudeBridge = a.allow ? 'on' : 'off';
454
517
  }
455
518
  if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
456
- if (a.type === 'previewBrief') {
457
- const s = sessions.find((x) => x.key === a.key);
458
- setTimeout(() => onBrief({ key: s.key, title: s.title, path: `${s.cwd}/.harlay/HANDOFF.md`, text: sampleBrief(s.title) }), 600);
459
- return;
460
- }
461
- if (a.type === 'handoff') {
462
- const s = sessions.find((x) => x.key === a.key);
463
- 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;
464
523
  emit();
465
- 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);
466
527
  return;
467
528
  }
468
529
  emit();