harlay 0.2.1 → 0.3.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.
@@ -9,5 +9,9 @@ import_electron.contextBridge.exposeInMainWorld("harlay", {
9
9
  addChat: (tool, id) => import_electron.ipcRenderer.invoke("harlay:addChat", { tool, id }),
10
10
  resize: (height) => import_electron.ipcRenderer.send("harlay:resize", height),
11
11
  hide: () => import_electron.ipcRenderer.send("harlay:hide"),
12
- ready: () => import_electron.ipcRenderer.send("harlay:ready")
12
+ ready: () => import_electron.ipcRenderer.send("harlay:ready"),
13
+ // A handoff as it runs (the popover), and its last moment over the target's window (the overlay).
14
+ onHandoff: (cb) => void import_electron.ipcRenderer.on("harlay:handoff", (_e, view) => cb(view)),
15
+ onOverlay: (cb) => void import_electron.ipcRenderer.on("harlay:overlay", (_e, play) => cb(play)),
16
+ overlayDone: () => import_electron.ipcRenderer.send("harlay:overlayDone")
13
17
  });
@@ -0,0 +1,76 @@
1
+ /* The handoff's last moment, over the whole display: see overlay.ts. Nothing here takes a click. */
2
+ @property --p { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
3
+ :root {
4
+ --accent-rgb: 59, 91, 246;
5
+ --accent: #3b5bf6;
6
+ --hi: #a9c3ff;
7
+ --ok-rgb: 30, 158, 90;
8
+ --sans: system-ui, -apple-system, "Helvetica Neue", sans-serif;
9
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
10
+ --ease: cubic-bezier(.2, .7, .1, 1);
11
+ }
12
+ * { box-sizing: border-box; margin: 0; padding: 0; }
13
+ html, body { width: 100%; height: 100%; background: transparent; overflow: hidden; }
14
+ body { font: 13px/1.3 var(--sans); color: #fff; -webkit-font-smoothing: antialiased; user-select: none; pointer-events: none; }
15
+ #stage { position: fixed; inset: 0; }
16
+
17
+ /* the light: a comet whose head is at its own origin, so it can be moved and turned along the path */
18
+ .comet { position: absolute; left: 0; top: 0; width: 130px; height: 4px; margin: -2px 0 0 -130px; transform-origin: 130px 2px; border-radius: 4px;
19
+ background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), .55) 45%, var(--accent) 80%, var(--hi));
20
+ filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), .9)) drop-shadow(0 0 16px rgba(var(--accent-rgb), .55)); }
21
+ .comet::after { content: ""; position: absolute; right: -5px; top: -4px; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 10px 4px rgba(var(--accent-rgb), .9), 0 0 26px 8px rgba(var(--accent-rgb), .45); }
22
+ .burst { position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%; border: 2px solid var(--hi); box-shadow: 0 0 18px 4px rgba(var(--accent-rgb), .6); animation: burst .7s var(--ease) forwards; }
23
+ @keyframes burst { from { opacity: 1; transform: scale(.4); } to { opacity: 0; transform: scale(5); } }
24
+
25
+ /* the target's window: its edge lights up from where the light lands (top middle) round to the bottom, then glows */
26
+ .ring { position: absolute; border-radius: 13px; animation: fade-out 1.1s ease 3.6s forwards; }
27
+ .ring-edge { position: absolute; inset: -2px; padding: 2.5px; border-radius: 15px;
28
+ background: conic-gradient(from 0deg, var(--hi), var(--accent) var(--p), transparent var(--p) calc(360deg - var(--p)), var(--accent) calc(360deg - var(--p)), var(--hi));
29
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude;
30
+ animation: edge 1s cubic-bezier(.55, 0, .2, 1) forwards; }
31
+ @keyframes edge { to { --p: 180deg; } }
32
+ .ring-glow { position: absolute; inset: -2px; border-radius: 15px; opacity: 0;
33
+ box-shadow: 0 0 0 1px rgba(var(--accent-rgb), .55), 0 0 30px 4px rgba(var(--accent-rgb), .45), 0 0 90px 12px rgba(var(--accent-rgb), .22), inset 0 0 56px rgba(var(--accent-rgb), .2);
34
+ animation: glow-in .5s ease .8s forwards, breathe 1.5s ease-in-out 1.3s 2; }
35
+ @keyframes glow-in { to { opacity: 1; } }
36
+ @keyframes breathe { 50% { opacity: .55; } }
37
+ @keyframes fade-out { to { opacity: 0; } }
38
+
39
+ /* no window found: the screen's edges glow instead */
40
+ .edge { position: absolute; inset: 0; opacity: 0; box-shadow: inset 0 0 0 2px rgba(var(--accent-rgb), .45), inset 0 0 70px 6px rgba(var(--accent-rgb), .32); animation: glow-in .5s ease forwards, fade-out 1s ease 2.4s forwards; }
41
+
42
+ /* the widget, on the window's top edge */
43
+ .pill { position: absolute; display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 8px 0 9px; border-radius: 999px; white-space: nowrap; transform: translate(-50%, -50%);
44
+ background: linear-gradient(180deg, #6582ff, var(--accent));
45
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .3), inset 0 0 0 1px rgba(255, 255, 255, .14), 0 12px 32px -8px rgba(var(--accent-rgb), .75), 0 0 46px 4px rgba(var(--accent-rgb), .32);
46
+ transition: background .35s, box-shadow .35s; }
47
+ .pill.got { background: linear-gradient(180deg, #3ccb84, #1e9e5a); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .3), inset 0 0 0 1px rgba(255, 255, 255, .14), 0 12px 32px -8px rgba(var(--ok-rgb), .75), 0 0 46px 4px rgba(var(--ok-rgb), .3); }
48
+ .pill > * { animation: content-in .32s var(--ease) .2s backwards; }
49
+ @keyframes content-in { from { opacity: 0; transform: translateY(3px); } }
50
+ .mk { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: rgba(255, 255, 255, .18); }
51
+ .mk svg { width: 14px; height: 14px; }
52
+ .txt { display: flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 500; letter-spacing: -.01em; }
53
+ .txt .dim { opacity: .78; }
54
+ .txt b { font-weight: 650; }
55
+ .agent { width: 20px; height: 20px; border-radius: 6px; box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .4); }
56
+ .agent.badge { display: grid; place-items: center; background: rgba(255, 255, 255, .2); font: 600 8.5px var(--mono); }
57
+ .act { display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 11px 0 6px; border-radius: 999px; background: rgba(0, 0, 0, .14); font-size: 12px; font-weight: 500; }
58
+ .act kbd { display: grid; place-items: center; min-width: 22px; height: 20px; padding: 0 5px; border-radius: 6px; background: #fff; color: var(--accent); font: 600 12px var(--sans); box-shadow: 0 1px 0 rgba(0, 0, 0, .15); animation: key 1.3s ease-in-out infinite; }
59
+ @keyframes key { 50% { box-shadow: 0 1px 0 rgba(0, 0, 0, .15), 0 0 0 4px rgba(255, 255, 255, .28); } }
60
+ .dots { display: flex; gap: 3px; padding-left: 4px; }
61
+ .dots i { width: 4px; height: 4px; border-radius: 50%; background: #fff; animation: dot 1s ease-in-out infinite; }
62
+ .dots i:nth-child(2) { animation-delay: .15s; }
63
+ .dots i:nth-child(3) { animation-delay: .3s; }
64
+ @keyframes dot { 0%, 100% { opacity: .35; transform: none; } 40% { opacity: 1; transform: translateY(-2px); } }
65
+ .check { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #fff; color: #1e9e5a; }
66
+ .check svg { width: 13px; height: 13px; }
67
+ .got .txt { padding-right: 8px; }
68
+
69
+ /* in a browser (design work): a pretend desktop with the window */
70
+ body.demo { background: radial-gradient(120% 90% at 30% 10%, #2a3350, #11131a); }
71
+ .demo-win { position: absolute; border-radius: 13px; background: #1d1f26; box-shadow: 0 30px 80px -20px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(255, 255, 255, .08); }
72
+ .demo-bar { position: absolute; inset: 0 0 auto; height: 30px; background: rgba(255, 255, 255, .92); }
73
+
74
+ @media (prefers-reduced-motion: reduce) {
75
+ *, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0s !important; transition: none !important; }
76
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:">
6
+ <title>Harlay</title>
7
+ <link rel="stylesheet" href="overlay.css">
8
+ </head>
9
+ <body>
10
+ <div id="stage" aria-live="polite"></div>
11
+ <script src="overlay.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // window.harlay comes from the preload script; opened in a browser, the overlay plays on a pretend desktop.
4
+ const api = window.harlay ?? demoApi();
5
+
6
+ const stage = document.getElementById('stage');
7
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
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
+ 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' };
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 };
13
+ /** How long it stays once the agent did. */
14
+ const GOT_MS = 1_900;
15
+
16
+ /** The play on screen: a newer one replaces it, and its timers check this before they act. */
17
+ let playing = null;
18
+
19
+ function el(tag, cls, parent = stage) {
20
+ const e = document.createElement(tag);
21
+ if (cls) e.className = cls;
22
+ parent.append(e);
23
+ return e;
24
+ }
25
+
26
+ /** Places an element through the CSSOM (the page's CSP forbids style attributes). */
27
+ function place(e, box) {
28
+ for (const [k, v] of Object.entries(box)) e.style[k] = `${v}px`;
29
+ return e;
30
+ }
31
+
32
+ function svg(markup, cls, parent) {
33
+ const e = el('span', cls, parent);
34
+ e.innerHTML = markup; // constant markup only
35
+ return e;
36
+ }
37
+
38
+ /** A point on the quadratic curve a → c → b, and the curve's direction there. */
39
+ function curve(a, c, b, t) {
40
+ const u = 1 - t;
41
+ return {
42
+ x: u * u * a.x + 2 * u * t * c.x + t * t * b.x,
43
+ y: u * u * a.y + 2 * u * t * c.y + t * t * b.y,
44
+ angle: Math.atan2(2 * u * (c.y - a.y) + 2 * t * (b.y - c.y), 2 * u * (c.x - a.x) + 2 * t * (b.x - c.x)),
45
+ };
46
+ }
47
+
48
+ /** The light: from the menu bar icon along the top, then down onto the window's edge. */
49
+ function fly(a, b) {
50
+ const comet = el('div', 'comet');
51
+ // Along the menu bar first, then a curve down: the control point sits above the landing point.
52
+ const c = { x: b.x + (a.x - b.x) * 0.12, y: a.y };
53
+ const n = 32;
54
+ const frames = [];
55
+ for (let i = 0; i <= n; i++) {
56
+ const p = curve(a, c, b, i / n);
57
+ frames.push({ transform: `translate(${p.x}px, ${p.y}px) rotate(${p.angle}rad)`, opacity: i === 0 ? 0 : 1, offset: i / n });
58
+ }
59
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
60
+ const anim = comet.animate(frames, { duration: Math.min(1000, Math.max(620, 520 + dist * 0.25)), easing: 'cubic-bezier(.62, 0, .22, 1)', fill: 'forwards' });
61
+ return anim.finished.then(() => {
62
+ place(el('div', 'burst'), { left: b.x, top: b.y });
63
+ comet.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: 'forwards' }).finished.then(() => comet.remove());
64
+ });
65
+ }
66
+
67
+ function glowWindow(w) {
68
+ const ring = place(el('div', 'ring'), { left: w.x, top: w.y, width: w.width, height: w.height });
69
+ el('div', 'ring-edge', ring);
70
+ el('div', 'ring-glow', ring);
71
+ }
72
+
73
+ function agentIcon(to, parent) {
74
+ if (to.icon) {
75
+ const img = el('img', 'agent', parent);
76
+ img.src = to.icon;
77
+ img.alt = '';
78
+ return img;
79
+ }
80
+ const b = el('span', 'agent badge', parent);
81
+ b.textContent = BADGE[to.tool] ?? '';
82
+ return b;
83
+ }
84
+
85
+ function fillPill(p, m) {
86
+ p.replaceChildren();
87
+ svg(MARK, 'mk', p);
88
+ const txt = el('span', 'txt', p);
89
+ el('span', 'dim', txt).textContent = 'Continuing in';
90
+ agentIcon(m.to, txt);
91
+ el('b', '', txt).textContent = m.to.label;
92
+ const act = el('span', 'act', p);
93
+ if (m.to.mode === 'app') {
94
+ el('kbd', '', act).textContent = '⏎';
95
+ act.append('to send');
96
+ } else {
97
+ const dots = el('span', 'dots', act);
98
+ for (let i = 0; i < 3; i++) el('i', '', dots);
99
+ act.append('picking it up');
100
+ }
101
+ }
102
+
103
+ function fillGot(p, m) {
104
+ p.replaceChildren();
105
+ svg(CHECK, 'check', p);
106
+ const txt = el('span', 'txt', p);
107
+ agentIcon(m.to, txt);
108
+ el('b', '', txt).textContent = m.to.label;
109
+ el('span', 'dim', txt).textContent = `has the context from ${m.fromLabel}`;
110
+ }
111
+
112
+ /** From a dot where the light landed to the whole widget. */
113
+ function grow(p) {
114
+ if (reduce) return;
115
+ p.animate(
116
+ [{ clipPath: 'inset(calc(50% - 6px) calc(50% - 6px) round 999px)' }, { clipPath: 'inset(0 0 round 999px)' }],
117
+ { duration: 560, easing: 'cubic-bezier(.2, 1.15, .3, 1)' },
118
+ );
119
+ }
120
+
121
+ async function play(m) {
122
+ // `early`: the agent picked it up before the widget was on screen.
123
+ const me = { m, pill: null, timer: null, early: !!m.picked };
124
+ playing = me;
125
+ stage.replaceChildren();
126
+ const w = m.window;
127
+ // Where the light lands: the middle of the window's top edge, kept below the menu bar.
128
+ const land = w ? { x: w.x + w.width / 2, y: Math.max(w.y, m.menuBar + 24) } : { x: innerWidth / 2, y: m.menuBar + 44 };
129
+ if (!reduce) await fly(m.from, land);
130
+ if (playing !== me) return;
131
+ if (w) glowWindow(w);
132
+ else el('div', 'edge');
133
+ me.pill = place(el('div', 'pill'), { left: land.x, top: land.y });
134
+ fillPill(me.pill, m);
135
+ grow(me.pill);
136
+ me.timer = setTimeout(() => finish(me), STAY_MS[m.to.mode] ?? STAY_MS.app);
137
+ // Long enough to read where it went before it says it got there.
138
+ if (me.early) setTimeout(() => playing === me && got(), 900);
139
+ }
140
+
141
+ /** The agent picked it up: the widget turns green and says so, then goes. */
142
+ function got() {
143
+ const me = playing;
144
+ if (!me) return;
145
+ if (!me.pill) {
146
+ me.early = true;
147
+ return;
148
+ }
149
+ if (me.pill.classList.contains('got')) return;
150
+ clearTimeout(me.timer);
151
+ me.pill.classList.add('got');
152
+ fillGot(me.pill, me.m);
153
+ me.timer = setTimeout(() => finish(me), GOT_MS);
154
+ }
155
+
156
+ async function finish(me) {
157
+ if (playing !== me) return;
158
+ const fades = [...stage.children].map((e) =>
159
+ e === me.pill && !reduce
160
+ ? e.animate([{ clipPath: 'inset(0 0 round 999px)', opacity: 1 }, { clipPath: 'inset(calc(50% - 6px) calc(50% - 6px) round 999px)', opacity: 0 }], { duration: 380, easing: 'cubic-bezier(.5, 0, .7, .4)', fill: 'forwards' }).finished
161
+ : e.animate([{ opacity: getComputedStyle(e).opacity }, { opacity: 0 }], { duration: 300, fill: 'forwards' }).finished,
162
+ );
163
+ await Promise.all(fades);
164
+ if (playing !== me) return;
165
+ playing = null;
166
+ stage.replaceChildren();
167
+ api.overlayDone();
168
+ }
169
+
170
+ api.onOverlay((m) => (m.read ? got() : void play(m)));
171
+
172
+ function demoApi() {
173
+ document.body.classList.add('demo');
174
+ const menuBar = 33;
175
+ const win = { x: Math.round(innerWidth * 0.18), y: Math.round(innerHeight * 0.22), width: Math.round(innerWidth * 0.6), height: Math.round(innerHeight * 0.6) };
176
+ const params = new URLSearchParams(location.search);
177
+ let onOverlay = () => {};
178
+ const run = () => {
179
+ const mode = params.get('mode') === 'terminal' ? 'terminal' : 'app';
180
+ onOverlay({ key: 'demo', from: { x: innerWidth - 120, y: menuBar / 2 }, window: params.has('nowindow') ? null : win, menuBar, to: { tool: 'cursor', label: 'Cursor', icon: null, mode }, fromLabel: 'Claude Code', picked: params.has('picked') });
181
+ if (!params.has('noread')) setTimeout(() => onOverlay({ read: true }), 4200);
182
+ };
183
+ addEventListener('load', () => {
184
+ place(el('div', 'demo-bar', document.body), { height: menuBar });
185
+ if (!params.has('nowindow')) document.body.prepend(place(el('div', 'demo-win', document.body), { left: win.x, top: win.y, width: win.width, height: win.height }));
186
+ setTimeout(run, 400);
187
+ });
188
+ return {
189
+ onOverlay: (cb) => { onOverlay = cb; },
190
+ overlayDone: () => setTimeout(run, 1200),
191
+ };
192
+ }
@@ -272,3 +272,46 @@ kbd { margin-right: 6px; padding: 1px 5px; border-radius: 5px; border: 1px solid
272
272
  .h-state { font-size: 12px; font-weight: 600; }
273
273
  .health.long .h-state { color: var(--health-long); }
274
274
  .health.struggling .h-state { color: var(--health-bad); }
275
+
276
+ /* a handoff as it runs: the two agents, then Detect, Extract, Distill, Rehydrate (as on harlay.dev) */
277
+ .panel.out { opacity: 0; transform: translateY(-4px) scale(.985); transition: opacity .2s ease-in, transform .2s ease-in; }
278
+ .top .tag { margin-left: auto; margin-right: 6px; }
279
+ .tag { padding: 2px 8px; border-radius: 999px; border: 1px solid; font: 500 10.5px var(--mono); white-space: nowrap; transition: color .25s, border-color .25s, background-color .25s; }
280
+ .tag.err { color: var(--bad); border-color: rgba(var(--bad-rgb), .3); background: rgba(var(--bad-rgb), .07); }
281
+ .tag.ok { color: var(--fresh); border-color: rgba(23, 160, 108, .3); background: rgba(23, 160, 108, .08); }
282
+ .tag.acc { color: var(--accent); border-color: rgba(var(--accent-rgb), .35); background: var(--accent-soft); }
283
+
284
+ .hf-route { display: grid; grid-template-columns: 72px 1fr 72px; align-items: start; margin: 2px 16px 0; padding: 14px 6px 12px; border-radius: 14px; background: var(--card); box-shadow: var(--rim), 0 0 0 1px var(--line2); }
285
+ .hf-end { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; }
286
+ .hf-end .agent { width: 34px; height: 34px; border-radius: 9px; }
287
+ .hf-end .agent.badge { font-size: 10.5px; }
288
+ .hf-end.to .tile-ic { border-radius: 10px; transition: box-shadow .4s var(--ease); }
289
+ .hf-route.done .hf-end.to .tile-ic { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .25), 0 8px 22px -6px rgba(var(--accent-rgb), .7); }
290
+ .hf-name { max-width: 100%; font-size: 11px; font-weight: 500; color: var(--fg2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
291
+ .hf-line { position: relative; height: 2px; margin-top: 16px; border-radius: 2px; background: repeating-linear-gradient(90deg, rgba(var(--accent-rgb), .55) 0 5px, transparent 5px 10px); background-size: 20px 2px; animation: hf-flow .6s linear infinite; }
292
+ .hf-line i { position: absolute; top: -3px; left: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .18), 0 0 14px 2px rgba(var(--accent-rgb), .55); animation: hf-travel 1.1s cubic-bezier(.65, 0, .35, 1) infinite; }
293
+ .hf-route.done .hf-line { background: linear-gradient(90deg, rgba(var(--accent-rgb), .35), var(--accent)); animation: none; }
294
+ .hf-route.done .hf-line i { left: calc(100% - 8px); animation: none; }
295
+ .hf-route.failed .hf-line { background: repeating-linear-gradient(90deg, rgba(var(--bad-rgb), .45) 0 5px, transparent 5px 10px); animation: none; }
296
+ .hf-route.failed .hf-line i { display: none; }
297
+ @keyframes hf-flow { to { background-position: 20px 0; } }
298
+ @keyframes hf-travel { 0% { left: 0; opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } 100% { left: calc(100% - 8px); opacity: 0; } }
299
+
300
+ .hf-title { margin: 13px 20px 0; font-size: 13.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
301
+ .hf-steps { list-style: none; display: grid; gap: 2px; margin: 8px 12px 12px; }
302
+ .hf-steps li { display: grid; grid-template-columns: 16px 1fr; column-gap: 11px; padding: 7px 8px; border-radius: 9px; color: var(--fg3); transition: background-color .3s, color .3s; }
303
+ .hf-step { font-size: 13px; font-weight: 500; }
304
+ .hf-steps em { grid-column: 2; font: normal 10.5px/1.5 var(--mono); color: var(--fg3); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
305
+ .hf-steps em b { font-weight: 600; color: var(--accent); }
306
+ .hf-steps .s { position: relative; width: 16px; height: 16px; margin-top: 1px; border-radius: 50%; border: 1.5px solid var(--line3); transition: background-color .25s, border-color .25s; }
307
+ .hf-steps li.run { color: var(--fg); background: var(--hover); }
308
+ .hf-steps li.run .s { border-color: var(--accent); border-right-color: transparent; animation: spin .7s linear infinite; }
309
+ .hf-steps li.done { color: var(--fg); }
310
+ .hf-steps li.done .s { border-color: var(--fresh); background: var(--fresh); animation: hf-pop .3s var(--ease); }
311
+ .hf-steps li.done .s::after { content: ""; position: absolute; left: 4.5px; top: 1.5px; width: 3.5px; height: 7px; border: solid #fff; border-width: 0 1.6px 1.6px 0; transform: rotate(45deg); }
312
+ .hf-steps li.fail { color: var(--fg); }
313
+ .hf-steps li.fail .s { border-color: var(--bad); background: var(--bad); }
314
+ .hf-steps li.fail .s::after { content: "!"; position: absolute; inset: 0; display: grid; place-items: center; font: 700 10px var(--sans); color: #fff; }
315
+ @keyframes hf-pop { 0% { transform: scale(.6); } 60% { transform: scale(1.15); } 100% { transform: none; } }
316
+ .hf-acts { margin: -2px 20px 14px; }
317
+ .hf-error { margin: -4px 20px 10px; font-size: 12px; color: var(--bad); overflow-wrap: anywhere; user-select: text; }
@@ -16,13 +16,15 @@
16
16
  harlay
17
17
  </div>
18
18
  <h1 class="view-title" id="view-title" hidden></h1>
19
+ <span class="tag" id="hf-tag" hidden></span>
19
20
  <button class="icon-btn" id="open-settings" aria-label="Settings" title="Settings"></button>
20
21
  </header>
21
22
  <section id="home"></section>
22
23
  <section id="route" hidden></section>
23
24
  <section id="usage" hidden></section>
24
25
  <section id="settings" hidden></section>
25
- <footer class="foot">
26
+ <section id="handoff" hidden aria-live="polite"></section>
27
+ <footer class="foot" id="foot">
26
28
  <span><kbd id="hotkey">⌥H</kbd>routes your latest chat</span>
27
29
  <button class="quit" id="quit">Quit</button>
28
30
  </footer>
@@ -374,16 +374,95 @@ function renderRoute() {
374
374
  h('button', { class: 'link r-all', onclick: () => go('home') }, icon('list'), 'All chats'))));
375
375
  }
376
376
 
377
+ // ---- a handoff as it runs (see presentHandoff in main.ts) ----
378
+
379
+ /** The handoff on show: its agents, its four steps, Distill's token counter. */
380
+ let hf = null;
381
+ /** Distill's counter as it runs: { from, to, t0 }, for the count it was started for. */
382
+ let counting = null;
383
+ const COUNT_MS = 900;
384
+ const fmt = (n) => Math.round(n).toLocaleString('en-US');
385
+
386
+ function hfTag() {
387
+ if (hf.tag === 'done') return ['handed off', 'ok'];
388
+ if (hf.tag === 'failed') return ['didn’t hand off', 'err'];
389
+ // A stopped chat says so until Harlay has read it; then it is on its way.
390
+ return hf.tag === 'stopped' && hf.steps[0].state === 'run' ? ['halt detected', 'err'] : ['handing off', 'acc'];
391
+ }
392
+
393
+ /** The counter's value now: the chat's tokens, easing down to the handoff file's. */
394
+ function countNow() {
395
+ if (!counting) return hf.count.from;
396
+ const p = Math.min(1, (performance.now() - counting.t0) / COUNT_MS);
397
+ return counting.from + (counting.to - counting.from) * (1 - Math.pow(1 - p, 3));
398
+ }
399
+
400
+ function tickCount() {
401
+ const el = $('#hf-count');
402
+ if (el && counting) el.textContent = fmt(countNow());
403
+ if (counting && performance.now() - counting.t0 < COUNT_MS) requestAnimationFrame(tickCount);
404
+ }
405
+
406
+ function stepDetail(step) {
407
+ if (step.name !== 'Distill' || !hf.count) return step.detail;
408
+ return [h('span', { class: 'hf-was' }, fmt(hf.count.from)), ' → ', h('b', { id: 'hf-count' }, fmt(countNow())), ' tokens'];
409
+ }
410
+
411
+ function renderHandoff() {
412
+ const fresh = hf.from.tool === hf.to.tool;
413
+ put('#handoff',
414
+ h('div', { class: `hf-route ${hf.tag}` },
415
+ h('span', { class: 'hf-end' }, agentIcon(hf.from.tool), h('span', { class: 'hf-name' }, hf.from.label)),
416
+ h('span', { class: 'hf-line', 'aria-hidden': 'true' }, h('i')),
417
+ 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),
421
+ h('span', { class: 'hf-name' }, hf.to.label))),
422
+ h('p', { class: 'hf-title', title: hf.title }, hf.title),
423
+ h('ol', { class: 'hf-steps' }, hf.steps.map((step) =>
424
+ h('li', { class: step.state }, h('span', { class: 's' }), h('span', { class: 'hf-step' }, step.name), h('em', {}, stepDetail(step))))),
425
+ hf.error ? h('p', { class: 'hf-error' }, hf.error) : null,
426
+ // A failed handoff stays until the user picks what's next (the panel may have opened without the keyboard).
427
+ hf.tag === 'failed'
428
+ ? h('div', { class: 'links hf-acts' },
429
+ h('button', { class: 'link', onclick: () => api.act(fresh ? { type: 'freshStart', key: hf.key } : { type: 'handoff', key: hf.key, target: hf.to.tool }) }, icon('fresh'), 'Try again'),
430
+ h('button', { class: 'link', onclick: () => { hf = null; go('home'); } }, icon('list'), 'All chats'),
431
+ h('button', { class: 'link quiet', onclick: () => api.hide() }, 'Close'))
432
+ : null);
433
+ const [text, cls] = hfTag();
434
+ $('#hf-tag').className = `tag ${cls}`;
435
+ $('#hf-tag').textContent = text;
436
+ }
437
+
438
+ function onHandoff(v) {
439
+ hf = v;
440
+ if (v.count && (!counting || counting.for !== `${v.key}:${v.count.from}:${v.count.to}`)) {
441
+ counting = { ...v.count, t0: performance.now(), for: `${v.key}:${v.count.from}:${v.count.to}` };
442
+ requestAnimationFrame(tickCount);
443
+ }
444
+ // Fades out as the overlay takes over (main hides the window right after).
445
+ $('#panel').classList.toggle('out', v.leaving);
446
+ if (view !== 'handoff') {
447
+ openKey = null;
448
+ adding = null;
449
+ }
450
+ go('handoff');
451
+ }
452
+
377
453
  // ---- frame ----
378
454
 
379
455
  function render() {
380
456
  if (!model) return;
381
- for (const v of ['home', 'route', 'usage', 'settings']) $(`#${v}`).hidden = view !== v;
382
- const home = view === 'home' || view === 'route';
457
+ for (const v of ['home', 'route', 'usage', 'settings', 'handoff']) $(`#${v}`).hidden = view !== v;
458
+ const handing = view === 'handoff' && !!hf;
459
+ const home = view === 'home' || view === 'route' || handing;
383
460
  $('#brand').hidden = !home;
384
- $('#open-settings').hidden = !home;
461
+ $('#open-settings').hidden = !home || handing;
462
+ $('#hf-tag').hidden = !handing;
463
+ $('#foot').hidden = handing;
385
464
  // The router (⌥H) goes back to all chats too.
386
- $('#back').hidden = view === 'home';
465
+ $('#back').hidden = view === 'home' || handing;
387
466
  $('#back').title = view === 'route' ? 'All chats' : 'Back';
388
467
  $('#view-title').hidden = home;
389
468
  $('#view-title').textContent = TITLE[view] ?? '';
@@ -391,6 +470,7 @@ function render() {
391
470
  if (view === 'home') renderHome();
392
471
  else if (view === 'route') renderRoute();
393
472
  else if (view === 'usage') renderUsage();
473
+ else if (handing) renderHandoff();
394
474
  else renderSettings();
395
475
  }
396
476
 
@@ -404,8 +484,10 @@ function show(opts) {
404
484
  adding = null;
405
485
  routeKey = opts?.route ?? null;
406
486
  routeSel = 0;
487
+ hf = null;
407
488
  go(routeKey && findSession(routeKey) ? 'route' : 'home');
408
489
  const panel = $('#panel');
490
+ panel.classList.remove('out');
409
491
  // Otherwise the window gives focus back to the last button clicked (often the gear), with a keyboard focus ring.
410
492
  panel.focus({ preventScroll: true });
411
493
  panel.classList.remove('in');
@@ -431,7 +513,7 @@ document.addEventListener('keydown', (e) => {
431
513
  }
432
514
  }
433
515
  if (e.key !== 'Escape') return;
434
- if (view === 'route') api.hide();
516
+ if (view === 'route' || view === 'handoff') api.hide();
435
517
  else if (view !== 'home') go('home');
436
518
  else if (adding) { adding = null; render(); }
437
519
  else api.hide();
@@ -447,6 +529,7 @@ api.onModel((m) => {
447
529
  render();
448
530
  });
449
531
  api.onShown(show);
532
+ api.onHandoff?.(onHandoff);
450
533
  api.ready();
451
534
 
452
535
  function demoApi() {
@@ -478,6 +561,43 @@ function demoApi() {
478
561
  { ...chat('cursor', '7', 'Onboarding copy pass', '3h', null, 'handed_off', "Handed off to Codex · Codex hasn't picked it up"), delivery: 'missed' },
479
562
  ];
480
563
  let onModel = () => {};
564
+ let onHandoff = () => {};
565
+ let onShown = () => {};
566
+ /** The handoff's show, on main's timing (see presentHandoff). */
567
+ const demoShow = (c, to) => {
568
+ let v = {
569
+ key: c.key,
570
+ 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' },
572
+ title: c.title,
573
+ tag: c.status === 'limit' ? 'stopped' : 'moving',
574
+ steps: [
575
+ { name: 'Detect', state: 'run', detail: c.status === 'limit' ? 'usage limit, resets 3:00 PM' : 'moved by you' },
576
+ { name: 'Extract', state: 'wait', detail: 'reading the chat and git' },
577
+ { name: 'Distill', state: 'wait', detail: 'into one .harlay file' },
578
+ { name: 'Rehydrate', state: 'wait', detail: `opening ${label[to]}` },
579
+ ],
580
+ count: null,
581
+ error: null,
582
+ leaving: false,
583
+ };
584
+ const step = (name, state, detail) => { v = { ...v, steps: v.steps.map((x) => (x.name === name ? { ...x, state, detail: detail ?? x.detail } : x)) }; };
585
+ const fail = new URLSearchParams(location.search).has('fail');
586
+ const timeline = [
587
+ [0, () => {}],
588
+ [380, () => { step('Detect', 'done'); step('Extract', 'run'); }],
589
+ [520, () => { step('Extract', 'done', '318 messages · 4 files changed'); step('Distill', 'run'); }],
590
+ [150, () => { v = { ...v, count: { from: 188_412, to: 3_106 } }; }],
591
+ [950, () => { step('Distill', 'done'); step('Rehydrate', 'run'); }],
592
+ fail
593
+ ? [700, () => { v = { ...v, tag: 'failed', error: 'Cursor CLI not found. Install it or switch Cursor to app mode.', steps: v.steps.map((x) => (x.state === 'run' ? { ...x, state: 'fail' } : x)) }; }]
594
+ : [700, () => { step('Rehydrate', 'done', `${label[to]} · press ⏎ to send`); v = { ...v, tag: 'done' }; }],
595
+ ...(fail ? [] : [[800, () => { v = { ...v, leaving: true }; }]]),
596
+ ];
597
+ let t = 0;
598
+ for (const [d, f] of timeline) setTimeout(() => { f(); onHandoff(v); }, (t += d));
599
+ if (!fail) setTimeout(() => onShown({}), t + 1500);
600
+ };
481
601
  const emit = () => onModel({
482
602
  groups: ['claude-code', 'codex', 'cursor'].map((tool) => ({
483
603
  tool,
@@ -495,7 +615,8 @@ function demoApi() {
495
615
  });
496
616
  return {
497
617
  onModel: (cb) => { onModel = cb; },
498
- onShown: (cb) => setTimeout(cb, 50),
618
+ onShown: (cb) => { onShown = cb; setTimeout(cb, 50); },
619
+ onHandoff: (cb) => { onHandoff = cb; },
499
620
  ready: emit,
500
621
  resize: () => {},
501
622
  hide: () => console.log('[demo] hide'),
@@ -522,6 +643,7 @@ function demoApi() {
522
643
  c.busy = true;
523
644
  emit();
524
645
  const to = label[a.target ?? c.tool];
646
+ demoShow(c, a.target ?? c.tool);
525
647
  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
648
  setTimeout(() => { c.delivery = 'read'; c.detail = `Handed off to ${to} · ${to} read it`; emit(); }, 4000);
527
649
  return;