harlay 0.2.1 → 0.3.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.
- package/app/dist/main.cjs +356 -62
- package/app/dist/preload.cjs +5 -1
- package/app/renderer/overlay.css +76 -0
- package/app/renderer/overlay.html +13 -0
- package/app/renderer/overlay.js +192 -0
- package/app/renderer/popover.css +43 -0
- package/app/renderer/popover.html +3 -1
- package/app/renderer/popover.js +128 -6
- package/dist/harlay.mjs +44 -7
- package/package.json +1 -1
package/app/dist/preload.cjs
CHANGED
|
@@ -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
|
+
}
|
package/app/renderer/popover.css
CHANGED
|
@@ -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
|
-
<
|
|
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>
|
package/app/renderer/popover.js
CHANGED
|
@@ -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
|
|
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;
|