harlay 0.3.1 → 0.4.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.
@@ -7,9 +7,9 @@ const stage = document.getElementById('stage');
7
7
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
8
8
  const MARK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 20 10 4h4.3L6.8 20z" fill="#fff" opacity=".6"/><path d="M10.2 20 17.7 4H22l-7.5 16z" fill="#fff"/></svg>';
9
9
  const CHECK = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7"/></svg>';
10
- const BADGE = { 'claude-code': 'CC', codex: 'CX', cursor: 'CU' };
10
+ const BADGE = { 'claude-code': 'CC', codex: 'CX', cursor: 'CU', 'claude-web': 'C', chatgpt: 'G' };
11
11
  /** How long the widget stays when the agent doesn't pick the handoff up meanwhile. */
12
- const STAY_MS = { app: 10_000, terminal: 8_000 };
12
+ const STAY_MS = { app: 10_000, terminal: 8_000, web: 10_000 };
13
13
  /** How long it stays once the agent did. */
14
14
  const GOT_MS = 1_900;
15
15
 
@@ -90,7 +90,8 @@ function fillPill(p, m) {
90
90
  agentIcon(m.to, txt);
91
91
  el('b', '', txt).textContent = m.to.label;
92
92
  const act = el('span', 'act', p);
93
- if (m.to.mode === 'app') {
93
+ // An app or a web chat waits for Enter; a terminal starts at once.
94
+ if (m.to.mode === 'app' || m.to.mode === 'web') {
94
95
  el('kbd', '', act).textContent = '⏎';
95
96
  act.append('to send');
96
97
  } else {
@@ -246,16 +246,24 @@ kbd { margin-right: 6px; padding: 1px 5px; border-radius: 5px; border: 1px solid
246
246
  .tile-meter i { display: block; height: 100%; background: var(--accent); }
247
247
  .tile-meter i.warm { background: var(--long); }
248
248
  .tile-meter i.hot { background: var(--bad); }
249
- .tile-key { position: absolute; top: 5px; right: 6px; font: 500 9px var(--mono); color: var(--fg3); opacity: 0; transition: opacity .15s; }
250
- .tile.sel .tile-key, .tile:hover .tile-key { opacity: 1; }
251
249
  .r-busy { justify-content: center; height: 110px; }
252
- .r-foot { display: flex; align-items: center; justify-content: space-between; margin: 6px 12px 10px; }
253
- .r-hint { display: flex; gap: 4px; }
250
+ .r-more { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 2px; margin: 8px 12px 2px; }
251
+ .r-more-k { font-size: 11.5px; color: var(--fg3); margin-right: 2px; }
252
+ .chip { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 28px; padding: 0 8px 0 5px; border-radius: 9px; border: 1px solid transparent; font-size: 11.5px; font-weight: 500; color: var(--fg2); transition: background-color .15s, border-color .15s; }
253
+ .chip.sel { background: var(--card); border-color: rgba(var(--accent-rgb), .45); color: var(--fg); }
254
+ .chip .agent { width: 18px; height: 18px; border-radius: 5px; }
255
+ .chip .agent.badge { font-size: 8px; }
256
+ .chip .corner { right: -4px; bottom: -3px; min-width: 11px; height: 11px; padding: 0 1px; border-radius: 4px; }
257
+ .chip .corner svg { width: 8px; height: 8px; }
258
+ .chip .corner.mono { font-size: 6px; }
259
+ .r-folder { margin: 6px 16px 0; text-align: center; font-size: 11.5px; color: var(--fg3); }
260
+ .r-folder .link { font-size: 11.5px; font-weight: 600; }
261
+ .agent.badge.globe svg { width: 11px; height: 11px; }
262
+ .r-foot { display: flex; align-items: center; justify-content: flex-end; margin: 6px 12px 10px; }
254
263
  .r-acts { display: flex; align-items: center; gap: 14px; }
255
264
  .link.r-dismiss { color: var(--fg3); }
256
265
  .link.r-dismiss:hover { color: var(--fg); }
257
266
  .r-alert { margin: 4px 16px 8px; font-size: 12px; font-weight: 500; color: var(--bad); }
258
- .r-hint kbd { padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line3); font: 500 10px var(--mono); color: var(--fg3); }
259
267
 
260
268
  /* chat health: a battery with one look per state (fresh, long, struggling), and the state in words in the card */
261
269
  .battery { width: 22px; height: 11px; overflow: visible; }
@@ -12,11 +12,12 @@ const ICON = {
12
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
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
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
+ globe: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="5.6"/><path d="M2.6 8h10.8M8 2.4c1.6 1.6 2.3 3.5 2.3 5.6S9.6 12 8 13.6M8 2.4C6.4 4 5.7 5.9 5.7 8S6.4 12 8 13.6"/></svg>',
15
16
  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>',
16
17
  };
17
18
  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' };
19
+ const BADGE = { 'claude-code': 'CC', codex: 'CX', cursor: 'CU', 'claude-web': 'C', chatgpt: 'G' };
20
+ const AGENT_LABEL = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor', 'claude-web': 'claude.ai', chatgpt: 'ChatGPT' };
20
21
  const HEALTH_LABEL = { fresh: 'Healthy', long: 'Getting long', struggling: 'Running low' };
21
22
 
22
23
  let model = null;
@@ -187,11 +188,24 @@ function renderGroup(g) {
187
188
  g.sessions.length ? g.sessions.map((s) => renderSession({ ...s, agentLabel: g.label })) : h('p', { class: 'none' }, 'No chats yet'));
188
189
  }
189
190
 
191
+ /** Web chats Harlay read (⌥H on one, or a limit it spotted), first: each opens where it can go, to switch. */
192
+ function renderWebGroup() {
193
+ if (!model.webChats?.length) return null;
194
+ return h('section', { class: 'grp' },
195
+ h('div', { class: 'grp-h' }, h('span', { class: 'agent badge globe', 'aria-hidden': 'true' }, icon('globe')), h('span', { class: 'grp-name' }, 'Web chats')),
196
+ model.webChats.map((s) =>
197
+ h('div', { class: 'item' },
198
+ h('button', { class: 'row', title: s.title, onclick: () => { routeKey = s.key; routeSel = 0; go('route'); } },
199
+ h('span', { class: `st ${s.status}` }, s.status === 'idle' ? null : h('i')),
200
+ h('span', { class: 'title' }, s.title),
201
+ h('span', { class: 'time' }, s.ago)))));
202
+ }
203
+
190
204
  function renderHome() {
191
205
  const { groups } = model;
192
206
  put('#home',
193
- groups.length
194
- ? h('div', { class: 'scroll' }, groups.map(renderGroup))
207
+ groups.length || model.webChats?.length
208
+ ? h('div', { class: 'scroll' }, renderWebGroup(), groups.map(renderGroup))
195
209
  : h('p', { class: 'empty' }, 'Install Claude Code, Codex or Cursor and their chats show up here.'),
196
210
  model.usage?.length ? h('button', { class: 'more', onclick: () => go('usage') }, icon('gauge', 'gauge'), 'Check agent usage', icon('arrow', 'arrow')) : null);
197
211
  // Keep focus in the add-a-chat field across re-renders.
@@ -277,24 +291,40 @@ function renderSettings() {
277
291
  { value: 'dark', label: 'Dark' },
278
292
  ], settings.theme, (v) => api.act({ type: 'setTheme', theme: v }))),
279
293
  toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
294
+ toggleRow(
295
+ 'Web chats',
296
+ settings.webChats && !settings.webChatsAllowed
297
+ ? 'Allow Harlay in System Settings › Privacy & Security › Accessibility.'
298
+ : 'Spots limits on claude.ai and ChatGPT, and ⌥H moves the one in front.',
299
+ settings.webChats,
300
+ { type: 'toggleWebChats' }),
280
301
  toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' })));
281
302
  }
282
303
 
283
304
  function findSession(key) {
284
305
  for (const g of model.groups) for (const s of g.sessions) if (s.key === key) return s;
285
- return null;
306
+ return model.webChats?.find((s) => s.key === key) ?? null;
286
307
  }
287
308
 
309
+ /** The agent a chat is in, or a web chat's site. */
288
310
  function findTool(key) {
289
311
  for (const g of model.groups) if (g.sessions.some((s) => s.key === key)) return g.tool;
290
- return null;
312
+ return model.webChats?.find((s) => s.key === key)?.web ?? null;
313
+ }
314
+
315
+ /** For a web chat: the folder its agents open in, and a way to change it. */
316
+ function folderLine(s) {
317
+ if (!s.web || !s.targets.some((t) => t.mode !== 'web')) return null;
318
+ const name = model.agentFolder ? model.agentFolder.split('/').filter(Boolean).pop() : null;
319
+ const pick = (label) => h('button', { class: 'link', title: model.agentFolder ? `${model.agentFolder} · click to change` : 'Pick a folder', onclick: () => api.act({ type: 'pickFolder', key: s.key }) }, label);
320
+ return h('p', { class: 'r-folder' }, name ? ['Agents open in ', pick(name)] : ['Agents need a ', pick('folder')]);
291
321
  }
292
322
 
293
323
  // ---- router (⌥H): the latest chat, branching to the agents it can continue in ----
294
324
 
295
- /** Usage of an agent's fullest window, for the thin meter under its icon. */
325
+ /** Usage of an agent's fullest window, for the thin meter under its icon. claude.ai draws on Claude Code's plan. */
296
326
  function usageOf(tool) {
297
- const row = model.usage?.find((u) => u.tool === tool);
327
+ const row = model.usage?.find((u) => u.tool === (tool === 'claude-web' ? 'claude-code' : tool));
298
328
  if (!row || !row.windows.length) return null;
299
329
  return Math.max(...row.windows.map((w) => w.pct));
300
330
  }
@@ -323,12 +353,31 @@ function routeLines(n, selected) {
323
353
  return svg;
324
354
  }
325
355
 
326
- /** The router's choices: other agents (default first), then a new chat in the same agent. */
356
+ /**
357
+ * The router's choices: tiles for what's offered first (other agents and a new chat for a chat about code, web chats for
358
+ * one that only talked), and a row of smaller choices for the rest. `all` is both, in order, for the keys.
359
+ */
327
360
  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 } }));
361
+ const pick = (t) => ({ tool: t.tool, label: t.label, mode: t.mode, act: { type: 'handoff', key: s.key, target: t.tool } });
362
+ let tiles = s.targets.filter((t) => t.primary).map(pick);
363
+ let more = s.targets.filter((t) => !t.primary).map(pick);
329
364
  // 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 } });
331
- return out;
365
+ if (s.canStartFresh && s.status !== 'limit') (s.kind === 'chat' ? more : tiles).push({ tool, label: 'New chat', mode: null, fresh: true, act: { type: 'freshStart', key: s.key } });
366
+ if (!tiles.length) [tiles, more] = [more, []];
367
+ return { tiles, more, all: [...tiles, ...more] };
368
+ }
369
+
370
+ /** The small mark on a choice's icon: a new chat, a terminal, or the browser. */
371
+ function cornerOf(c) {
372
+ if (c.fresh) return h('span', { class: 'corner' }, icon('fresh'));
373
+ if (c.mode === 'terminal') return h('span', { class: 'corner mono' }, '>_');
374
+ if (c.mode === 'web') return h('span', { class: 'corner' }, icon('globe'));
375
+ return null;
376
+ }
377
+
378
+ function choiceTitle(c) {
379
+ if (c.fresh) return 'New chat that picks up where this one left off';
380
+ return `Continue in ${c.label}${c.mode === 'terminal' ? ' (Terminal)' : c.mode === 'web' ? ' in your browser' : ''}`;
332
381
  }
333
382
 
334
383
  let routeKey = null;
@@ -338,22 +387,20 @@ function renderRoute() {
338
387
  const s = routeKey ? findSession(routeKey) : null;
339
388
  if (!s) return go('home');
340
389
  const tool = findTool(s.key);
341
- const choices = routeChoices(s, tool);
342
- routeSel = Math.min(routeSel, Math.max(0, choices.length - 1));
390
+ const { tiles, more, all } = routeChoices(s, tool);
391
+ routeSel = Math.min(routeSel, Math.max(0, all.length - 1));
392
+ const hover = (i) => () => { if (routeSel !== i) { routeSel = i; render(); } };
343
393
  const tile = (c, i) => {
344
394
  const pct = c.fresh ? null : usageOf(c.tool);
345
395
  return h('button', {
346
396
  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(); } },
397
+ title: choiceTitle(c),
398
+ onmouseenter: hover(i),
349
399
  onclick: () => api.act(c.act),
350
400
  },
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),
401
+ h('span', { class: 'tile-ic' }, agentIcon(c.tool), cornerOf(c)),
354
402
  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)));
403
+ 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' : '')));
357
404
  };
358
405
  put('#route',
359
406
  // Opened by itself because this chat hit its limit: say so, and when it resets.
@@ -364,14 +411,23 @@ function renderRoute() {
364
411
  h('span', { class: 'r-src-time' }, s.ago)),
365
412
  s.busy
366
413
  ? 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))]
414
+ : tiles.length
415
+ ? [
416
+ routeLines(tiles.length, routeSel),
417
+ h('div', { class: `r-tiles n${tiles.length}` }, tiles.map(tile)),
418
+ more.length
419
+ ? h('div', { class: 'r-more' }, h('span', { class: 'r-more-k' }, 'Or'), more.map((c, j) => {
420
+ const i = tiles.length + j;
421
+ return h('button', { class: `chip${i === routeSel ? ' sel' : ''}`, title: choiceTitle(c), onmouseenter: hover(i), onclick: () => api.act(c.act) },
422
+ h('span', { class: 'tile-ic' }, agentIcon(c.tool), cornerOf(c)), c.label);
423
+ }))
424
+ : null,
425
+ folderLine(s),
426
+ ]
369
427
  : h('p', { class: 'none' }, 'No other agent installed'),
370
428
  h('div', { class: 'r-foot' },
371
- h('span', { class: 'r-hint' }, h('kbd', {}, '↵'), h('kbd', {}, '1–' + Math.max(1, choices.length))),
372
429
  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'))));
430
+ h('button', { class: 'link r-dismiss', onclick: () => api.hide() }, 'Not now'))));
375
431
  }
376
432
 
377
433
  // ---- a handoff as it runs (see presentHandoff in main.ts) ----
@@ -415,9 +471,7 @@ function renderHandoff() {
415
471
  h('span', { class: 'hf-end' }, agentIcon(hf.from.tool), h('span', { class: 'hf-name' }, hf.from.label)),
416
472
  h('span', { class: 'hf-line', 'aria-hidden': 'true' }, h('i')),
417
473
  h('span', { class: 'hf-end to' },
418
- h('span', { class: 'tile-ic' },
419
- agentIcon(hf.to.tool),
420
- fresh ? h('span', { class: 'corner' }, icon('fresh')) : hf.to.mode === 'terminal' ? h('span', { class: 'corner mono' }, '>_') : null),
474
+ h('span', { class: 'tile-ic' }, agentIcon(hf.to.tool), cornerOf({ fresh, mode: hf.to.mode })),
421
475
  h('span', { class: 'hf-name' }, hf.to.label))),
422
476
  h('p', { class: 'hf-title', title: hf.title }, hf.title),
423
477
  h('ol', { class: 'hf-steps' }, hf.steps.map((step) =>
@@ -503,7 +557,7 @@ $('#quit').addEventListener('click', () => api.act({ type: 'quit' }));
503
557
  document.addEventListener('keydown', (e) => {
504
558
  if (view === 'route' && !e.metaKey && !e.ctrlKey && !e.altKey) {
505
559
  const s = routeKey ? findSession(routeKey) : null;
506
- const choices = s && !s.busy ? routeChoices(s, findTool(s.key)) : [];
560
+ const choices = s && !s.busy ? routeChoices(s, findTool(s.key)).all : [];
507
561
  const n = Number(e.key);
508
562
  if (Number.isInteger(n) && n >= 1 && n <= choices.length) return api.act(choices[n - 1].act);
509
563
  if (e.key === 'Enter' && choices[routeSel]) return api.act(choices[routeSel].act);
@@ -533,8 +587,9 @@ api.onHandoff?.(onHandoff);
533
587
  api.ready();
534
588
 
535
589
  function demoApi() {
536
- const label = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' };
537
- const settings = { autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
590
+ const label = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor', 'claude-web': 'claude.ai', chatgpt: 'ChatGPT' };
591
+ const web = ['claude-web', 'chatgpt'];
592
+ const settings = { autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off', webChats: true, webChatsAllowed: true };
538
593
  let asked = false;
539
594
  const usage = [
540
595
  { 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 },
@@ -549,12 +604,12 @@ function demoApi() {
549
604
  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'],
550
605
  context: { tokens: 188_000, window: 258_400, compactions: 2 },
551
606
  };
552
- const chat = (tool, id, title, ago, health, status = 'idle', detail = 'Idle', pinned = false) =>
553
- ({ key: `${tool}:${id}`, tool, title, ago, status, detail, cwd: `/Users/you/${title.split(' ')[0].toLowerCase()}`, busy: false, pinned, health, canStartFresh: true, delivery: null });
607
+ const chat = (tool, id, title, ago, health, status = 'idle', detail = 'Idle', pinned = false, kind = 'coding') =>
608
+ ({ key: `${tool}:${id}`, tool, title, ago, status, detail, cwd: `/Users/you/${title.split(' ')[0].toLowerCase()}`, busy: false, pinned, health, canStartFresh: true, delivery: null, kind });
554
609
  const chats = [
555
610
  chat('claude-code', '1', 'Migrate auth to JWT', 'now', struggling, 'limit', 'Limit hit · resets 3:00 PM'),
556
611
  chat('claude-code', '2', 'Harlay landing page', '1h', long),
557
- chat('claude-code', '3', 'Software architecture diagram', '3h', fresh),
612
+ chat('claude-code', '3', 'Which database for a side project?', '3h', fresh, 'idle', 'Idle', false, 'chat'),
558
613
  chat('codex', '4', 'Fix flaky checkout tests', '4m', longer, 'warning', '92% of limit used'),
559
614
  chat('codex', '5', 'Rate limiter for /login', '2h', fresh),
560
615
  chat('codex', '6', 'Implement the task in Colab', '1d', fresh),
@@ -568,7 +623,7 @@ function demoApi() {
568
623
  let v = {
569
624
  key: c.key,
570
625
  from: { tool: c.tool, label: label[c.tool] },
571
- to: { tool: to, label: to === c.tool ? 'New chat' : label[to], mode: to === 'cursor' ? 'terminal' : 'app' },
626
+ to: { tool: to, label: to === c.tool ? 'New chat' : label[to], mode: to === 'cursor' ? 'terminal' : web.includes(to) ? 'web' : 'app' },
572
627
  title: c.title,
573
628
  tag: c.status === 'limit' ? 'stopped' : 'moving',
574
629
  steps: [
@@ -604,11 +659,19 @@ function demoApi() {
604
659
  label: label[tool],
605
660
  sessions: chats.filter((c) => c.tool === tool).map((c) => ({
606
661
  ...c,
607
- targets: ['codex', 'claude-code', 'cursor'].filter((t) => t !== tool).map((t) => ({ tool: t, label: label[t], mode: t === 'cursor' ? 'terminal' : 'app' })),
662
+ targets: [
663
+ ...['codex', 'claude-code', 'cursor'].filter((t) => t !== tool).map((t) => ({ tool: t, label: label[t], mode: t === 'cursor' ? 'terminal' : 'app', primary: c.kind !== 'chat' })),
664
+ ...web.filter((w) => !(w === 'claude-web' && tool === 'claude-code' && c.status === 'limit')).map((w) => ({ tool: w, label: label[w], mode: 'web', primary: c.kind === 'chat' })),
665
+ ].sort((a, b) => b.primary - a.primary),
608
666
  })),
609
667
  })),
610
668
  usage,
611
669
  icons: {},
670
+ webChats: [
671
+ { key: 'web:https://claude.ai/chat/1', web: 'claude-web', title: 'Trip plan for Tokyo', ago: 'now', status: 'limit', detail: 'Limit hit · resets 3:00 PM', cwd: null, busy: false, pinned: false, health: null, canStartFresh: false, delivery: null, kind: 'chat',
672
+ targets: [{ tool: 'chatgpt', label: 'ChatGPT', mode: 'web', primary: true }, { tool: 'codex', label: 'Codex', mode: 'app', primary: false }, { tool: 'cursor', label: 'Cursor', mode: 'terminal', primary: false }] },
673
+ ],
674
+ agentFolder: '/Users/you/robotic-harness',
612
675
  askClaudeBridge: !asked,
613
676
  settings: { ...settings },
614
677
  hotkey: '⌥H',
@@ -631,6 +694,7 @@ function demoApi() {
631
694
  console.log('[demo] action', a);
632
695
  if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
633
696
  if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
697
+ if (a.type === 'toggleWebChats') settings.webChats = !settings.webChats;
634
698
  if (a.type === 'setTheme') settings.theme = a.theme;
635
699
  if (a.type === 'answerClaudeBridge') {
636
700
  asked = true;