@leftoff/cli 0.9.8-beta.6 → 0.9.8-beta.8
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/CHANGELOG.md +4 -0
- package/bin/leftoff.js +320 -318
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +97 -9
- package/ui/app.js +221 -4
- package/ui/index.html +4 -0
- package/ui/live-status.js +11 -4
- package/ui/live-view.js +22 -4
- package/ui/push.js +140 -0
- package/ui/sw.js +52 -5
- package/ui/tabs.js +216 -0
package/docs/peer-protocol.md
CHANGED
|
@@ -111,6 +111,8 @@ Only Macs registered for that user can connect: the first with `leftoff relay re
|
|
|
111
111
|
|
|
112
112
|
Every few seconds (`peers.pollMs`, default 4 s) each daemon asks every machine that accepts for `needs.list`, so another Mac's permission prompts and questions show on this board with a ⌂ machine chip. Answer, reply and Go on another machine's session go out as envelopes. When that isn't possible, the button is grey and says why: not allowed on that machine, unreachable, key changed, no key yet (that Mac needs 0.5.2), or no route. Answering from a phone keeps its own rules (Settings → Privacy & security), and Go stays a local-browser action.
|
|
113
113
|
|
|
114
|
+
Phone notifications (0.9.8) ride the same envelopes: `push.sync` carries the Web Push subscriptions a browser turned on (endpoint, keys, a device label) with the VAPID keys they were made with, plus 30-day removal tombstones; each side merges (newer word per endpoint wins) and answers with its own, so every Mac can push for its own sessions and only those. It is answered even when a Mac doesn't allow incoming commands, because it runs nothing there. Kept on each Mac in `~/.config/checkpoints/push/` (0600), never in the synced folder. Shared when a device subscribes or unsubscribes, at start, and every 20 minutes.
|
|
115
|
+
|
|
114
116
|
## Pairing through Syncthing (0.5.3)
|
|
115
117
|
|
|
116
118
|
Before two Macs share a folder they have no channel to each other, so pairing rides on Syncthing itself. Leftoff never opens a port for it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.8",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.8' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.8",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -2141,10 +2141,10 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2141
2141
|
}
|
|
2142
2142
|
|
|
2143
2143
|
/* ------------------------------------------------------------ the sidebar rail (0.9.8)
|
|
2144
|
-
From
|
|
2144
|
+
From 700px up (0.9.8 tabs: was 820): a narrow rail on the left. The Leftoff mark at the top, then
|
|
2145
2145
|
the sections as an icon over a small label, the current one in a soft pill,
|
|
2146
2146
|
a red count on Needs you; search, What's new, Settings and this Mac's status
|
|
2147
|
-
(the Mac switcher) at the bottom. Under
|
|
2147
|
+
(the Mac switcher) at the bottom; 700–1099px it shows icons only. Under 700px: the mark, search and status in
|
|
2148
2148
|
a slim top row, and a bottom tab bar (Board, Needs you, Lists, PRs, More). */
|
|
2149
2149
|
:root { --rail-w: 84px; --rail-on: color-mix(in srgb, var(--accent) 13%, transparent); --bar-h: 58px; }
|
|
2150
2150
|
.tab-more { display: none; }
|
|
@@ -2153,7 +2153,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2153
2153
|
.moredot[hidden] { display: none; }
|
|
2154
2154
|
.iconbtn .lbl { display: none; }
|
|
2155
2155
|
.tabs a, .tab-more { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
|
2156
|
-
@media (min-width:
|
|
2156
|
+
@media (min-width: 700px) {
|
|
2157
2157
|
body { padding-left: calc(var(--rail-w) + env(safe-area-inset-left)); }
|
|
2158
2158
|
.top { position: fixed !important; top: 0; bottom: 0; left: 0; width: calc(var(--rail-w) + env(safe-area-inset-left)); z-index: 20; /* !important: the rail never joins the page flow */
|
|
2159
2159
|
flex-direction: column; flex-wrap: nowrap; align-items: stretch; justify-content: flex-start; gap: 6px; min-height: 0;
|
|
@@ -2181,11 +2181,11 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2181
2181
|
.newdot, .topdot { top: 8px; right: calc(50% - 15px); box-shadow: 0 0 0 2px var(--bg); }
|
|
2182
2182
|
.set-nav { top: 24px; }
|
|
2183
2183
|
}
|
|
2184
|
-
@media (min-width:
|
|
2184
|
+
@media (min-width: 700px) and (max-height: 640px) {
|
|
2185
2185
|
.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 46px; padding: 4px 2px; gap: 2px; }
|
|
2186
2186
|
.brand { padding-bottom: 4px; }
|
|
2187
2187
|
}
|
|
2188
|
-
@media (max-width:
|
|
2188
|
+
@media (max-width: 699.98px) {
|
|
2189
2189
|
.top { flex-wrap: nowrap; gap: 8px; min-height: 52px; padding-top: max(6px, env(safe-area-inset-top)); padding-bottom: 6px; background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
2190
2190
|
.brand { order: 1; }
|
|
2191
2191
|
.topright { order: 2; margin-left: auto; gap: 2px; }
|
|
@@ -2224,7 +2224,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2224
2224
|
.moremenu a.on { background: var(--rail-on); }
|
|
2225
2225
|
.moremenu a.on .ic { color: var(--accent); }
|
|
2226
2226
|
.moremenu .newdot, .moremenu .topdot { position: static; margin-left: auto; box-shadow: none; }
|
|
2227
|
-
@media (min-width:
|
|
2227
|
+
@media (min-width: 700px) { .moremenu { display: none !important; } }
|
|
2228
2228
|
/* Quick look on Needs you (0.9.8) */
|
|
2229
2229
|
.quicklook { position: fixed; z-index: 45; width: 360px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; overscroll-behavior: contain;
|
|
2230
2230
|
background: var(--card); color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 16px 40px rgba(15, 20, 35, .18), 0 2px 6px rgba(15, 20, 35, .08); padding: 12px 12px 8px; font-size: 13.5px; animation: lo-in .12s ease-out; }
|
|
@@ -2254,8 +2254,8 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2254
2254
|
.ql-foot { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 44px; padding: 0 6px; margin-top: 2px; color: var(--accent); font-weight: 600; text-decoration: none; border-radius: 8px; }
|
|
2255
2255
|
.ql-foot:hover { text-decoration: underline; }
|
|
2256
2256
|
.ql-foot:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2257
|
-
@media (pointer: coarse), (max-width:
|
|
2258
|
-
@media (max-width:
|
|
2257
|
+
@media (pointer: coarse), (max-width: 699.98px) { .ql-actions .btn { min-height: 44px; min-width: 64px; } .ql-main { min-height: 44px; justify-content: center; } }
|
|
2258
|
+
@media (max-width: 699.98px) { .quicklook { left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); width: auto; max-width: none; bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); max-height: calc(100vh - var(--bar-h) - 80px); } }
|
|
2259
2259
|
@media (prefers-reduced-motion: reduce) { .quicklook, .moremenu { animation: none; } }
|
|
2260
2260
|
|
|
2261
2261
|
/* Pull to refresh (phones, 0.9.8): the spinner comes down from under the top row */
|
|
@@ -2267,7 +2267,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2267
2267
|
.ptr-ring svg { width: 19px; height: 19px; }
|
|
2268
2268
|
.ptr.armed { color: var(--accent); }
|
|
2269
2269
|
.ptr.busy .ptr-ring { animation: spin .8s linear infinite; }
|
|
2270
|
-
@media (pointer: coarse) and (max-width:
|
|
2270
|
+
@media (pointer: coarse) and (max-width: 699.98px) { html, body { overscroll-behavior-y: contain; } }
|
|
2271
2271
|
@media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
|
|
2272
2272
|
@keyframes lo-pulse { 50% { opacity: .35; } }
|
|
2273
2273
|
|
|
@@ -2313,3 +2313,91 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2313
2313
|
.card > .actions-row > .btn.icon-btn { width: 44px; }
|
|
2314
2314
|
.card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
|
|
2315
2315
|
}
|
|
2316
|
+
|
|
2317
|
+
/* Phone notifications (0.9.8): the one per-device button, in More and Settings → General */
|
|
2318
|
+
.push-slot[hidden] { display: none; }
|
|
2319
|
+
.push-box { display: flex; gap: 10px; align-items: flex-start; }
|
|
2320
|
+
.push-box > .ic { width: 20px; height: 20px; flex: none; margin-top: 2px; color: var(--muted); }
|
|
2321
|
+
.push-main { min-width: 0; flex: 1; }
|
|
2322
|
+
.push-line { margin: 0; font-size: 14px; line-height: 1.4; color: var(--text2); }
|
|
2323
|
+
.push-line strong { color: var(--text); font-weight: 600; }
|
|
2324
|
+
.push-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
2325
|
+
.push-acts .btn { min-height: 44px; padding: 0 14px; font-size: 14px; white-space: nowrap; justify-content: center; }
|
|
2326
|
+
.push-note { margin: 8px 0 0; font-size: 13px; line-height: 1.4; color: var(--muted); }
|
|
2327
|
+
.push-note.bad { color: var(--red, #c0392b); }
|
|
2328
|
+
.moremenu .push-slot { border-top: 1px solid var(--line); margin-top: 6px; padding: 12px 8px 6px; }
|
|
2329
|
+
.moremenu:has(.push-slot:not([hidden])) { width: 270px; }
|
|
2330
|
+
.form .push-slot { grid-column: 1 / -1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card2, transparent); margin-top: 4px; }
|
|
2331
|
+
|
|
2332
|
+
/* ------------------------------------------------------------ tabs for sessions (0.9.8)
|
|
2333
|
+
By width, never by device. Under 700px: no strip; an Open row on the board
|
|
2334
|
+
and "‹ 2 of 4 ›" on a session page (swipe works too). 700–1099px: the strip,
|
|
2335
|
+
the rail as icons. From 1100px: the full rail and the strip. */
|
|
2336
|
+
.tabstrip, .openrow, .tabpager { display: none; }
|
|
2337
|
+
.tabstrip[hidden], .openrow[hidden], .tabpager[hidden] { display: none !important; }
|
|
2338
|
+
.tab-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2339
|
+
.preview .tab-t, .tab-s.preview .tab-t, .ochip.preview .tab-t { font-style: italic; }
|
|
2340
|
+
.tab-live, .tab-need { flex: none; width: 8px; height: 8px; border-radius: 50%; position: relative; }
|
|
2341
|
+
.tab-live { background: var(--green); animation: live-pulse 1.6s ease-in-out infinite; }
|
|
2342
|
+
.tab-need { background: var(--red); }
|
|
2343
|
+
@media (prefers-reduced-motion: reduce) { .tab-live { animation: none; } }
|
|
2344
|
+
@media (min-width: 700px) {
|
|
2345
|
+
.tabstrip { display: flex; position: sticky; top: 0; z-index: 15; align-items: stretch; height: 44px; background: var(--bg); border-bottom: 1px solid var(--line); padding-top: env(safe-area-inset-top); }
|
|
2346
|
+
.tabstrip-in { flex: 1; min-width: 0; display: flex; align-items: stretch; }
|
|
2347
|
+
.tab-scroll { flex: 1; min-width: 0; display: flex; align-items: stretch; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain; }
|
|
2348
|
+
.tab-scroll::-webkit-scrollbar { display: none; }
|
|
2349
|
+
.tab { display: flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 12px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
|
2350
|
+
.tab .ic { width: 16px; height: 16px; flex: none; }
|
|
2351
|
+
.tab:hover { color: var(--text); }
|
|
2352
|
+
.tab:focus-visible, .tab-x:focus-visible, .tab-all:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2353
|
+
.tab-board { flex: none; border-right: 1px solid var(--line); }
|
|
2354
|
+
.tab-cell { position: relative; flex: 0 1 200px; min-width: 120px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
|
|
2355
|
+
.tab-s { flex: 1; min-width: 0; padding-right: 0; }
|
|
2356
|
+
.tab.on, .tab-cell.on { color: var(--text); background: var(--card); }
|
|
2357
|
+
.tab-board.on, .tab-cell.on { box-shadow: inset 0 -2px 0 var(--accent); }
|
|
2358
|
+
.tab-x { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; border-radius: 8px; }
|
|
2359
|
+
.tab-x .ic { width: 14px; height: 14px; }
|
|
2360
|
+
.tab-x:hover { color: var(--text); background: var(--bg2); }
|
|
2361
|
+
.tab-keep { opacity: 0; }
|
|
2362
|
+
.tab-cell:hover .tab-keep, .tab-keep:focus-visible, .tab-cell.on .tab-keep { opacity: 1; }
|
|
2363
|
+
.tab-cell.dragging { opacity: .5; }
|
|
2364
|
+
.tab-cell.drop-before { box-shadow: inset 2px 0 0 var(--accent); }
|
|
2365
|
+
.tab-all { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; border-left: 1px solid var(--line); background: var(--bg); color: var(--muted); cursor: pointer; }
|
|
2366
|
+
.tab-all .ic { width: 18px; height: 18px; }
|
|
2367
|
+
.tab-all:hover, .tab-all[aria-expanded="true"] { color: var(--text); background: var(--bg2); }
|
|
2368
|
+
.tab-all[hidden] { display: none; }
|
|
2369
|
+
}
|
|
2370
|
+
@media (min-width: 700px) and (pointer: coarse) { .tab-keep { opacity: 1; } }
|
|
2371
|
+
/* the rail as icons (700–1099px): the labels stay for screen readers */
|
|
2372
|
+
@media (min-width: 700px) and (max-width: 1099.98px) {
|
|
2373
|
+
:root { --rail-w: 64px; }
|
|
2374
|
+
.tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
2375
|
+
.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 48px; padding: 6px 2px; }
|
|
2376
|
+
.navcount { left: calc(50% + 2px); }
|
|
2377
|
+
}
|
|
2378
|
+
/* phones (under 700px): the Open row on the board, ‹ N of M › on a session page */
|
|
2379
|
+
@media (max-width: 699.98px) {
|
|
2380
|
+
body[data-route="board"] .openrow { display: block; }
|
|
2381
|
+
body[data-route="s"] .tabpager { display: block; }
|
|
2382
|
+
.openrow { padding: 8px 0 0; }
|
|
2383
|
+
.openrow-h { display: flex; gap: 8px; align-items: baseline; padding: 0 max(16px, env(safe-area-inset-left)); font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--muted); }
|
|
2384
|
+
.openrow-chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px max(16px, env(safe-area-inset-left)) 6px; scroll-snap-type: x proximity; overscroll-behavior-x: contain; }
|
|
2385
|
+
.openrow-chips::-webkit-scrollbar { display: none; }
|
|
2386
|
+
.ochip-cell { flex: none; display: flex; align-items: stretch; max-width: 62vw; border: 1px solid var(--line); border-radius: 22px; background: var(--card); scroll-snap-align: start; }
|
|
2387
|
+
.ochip-cell.preview { border-style: dashed; }
|
|
2388
|
+
.ochip { display: flex; align-items: center; gap: 7px; min-height: 44px; min-width: 44px; padding: 0 14px; color: var(--text); text-decoration: none; font-size: 15px; max-width: 100%; min-width: 0; }
|
|
2389
|
+
.ochip-cell:not(.preview) .ochip { padding-right: 2px; }
|
|
2390
|
+
.ochip-x { flex: none; width: 44px; min-height: 44px; border: 0; background: none; color: var(--muted); display: flex; align-items: center; justify-content: center; padding: 0; border-radius: 22px; }
|
|
2391
|
+
.ochip-x .ic { width: 15px; height: 15px; }
|
|
2392
|
+
.tabpager { padding: 4px max(8px, env(safe-area-inset-left)) 0; }
|
|
2393
|
+
.pager-in { display: flex; align-items: center; gap: 6px; }
|
|
2394
|
+
.pager-b { flex: none; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 22px; color: var(--text); text-decoration: none; }
|
|
2395
|
+
.pager-b .ic { width: 22px; height: 22px; }
|
|
2396
|
+
.pager-b.off { opacity: .25; }
|
|
2397
|
+
.pager-b:not(.off):active { background: var(--bg2); }
|
|
2398
|
+
.pager-n { flex: 1; text-align: center; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
2399
|
+
.pager-keep { flex: none; min-height: 44px; }
|
|
2400
|
+
.swipe-next main { animation: lo-page-fwd .18s ease-out; }
|
|
2401
|
+
.swipe-prev main { animation: lo-page-back .18s ease-out; }
|
|
2402
|
+
}
|
|
2403
|
+
@media (prefers-reduced-motion: reduce) { .swipe-next main, .swipe-prev main { animation: none !important; } }
|
package/ui/app.js
CHANGED
|
@@ -19,8 +19,10 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
19
19
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
20
|
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
21
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
22
|
-
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses } from './live-view.js' // 0.9.8 Live link polish
|
|
22
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
|
|
23
23
|
import { statusView, elapsedText } from './live-status.js'
|
|
24
|
+
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
25
|
+
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, moveTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir } from './tabs.js' // 0.9.8 tabs for sessions
|
|
24
26
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
25
27
|
const $app = document.getElementById('app')
|
|
26
28
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
@@ -2616,6 +2618,7 @@ async function viewSettings() {
|
|
|
2616
2618
|
general: '<div class="section">Needs you</div>' +
|
|
2617
2619
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
2618
2620
|
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
2621
|
+
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
2619
2622
|
'<div class="section">Appearance</div>' +
|
|
2620
2623
|
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
2621
2624
|
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
@@ -3378,6 +3381,7 @@ function parseRoute() {
|
|
|
3378
3381
|
async function render() {
|
|
3379
3382
|
const r = (state.route = parseRoute())
|
|
3380
3383
|
paintNav(r)
|
|
3384
|
+
tabsOnRoute(r)
|
|
3381
3385
|
try {
|
|
3382
3386
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
3383
3387
|
else if (r.name === 'waiting') await viewWaiting()
|
|
@@ -3392,6 +3396,7 @@ async function render() {
|
|
|
3392
3396
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3393
3397
|
else if (r.name === 'setup') await viewSetup()
|
|
3394
3398
|
else await viewBoard()
|
|
3399
|
+
tabsAfterRender()
|
|
3395
3400
|
} catch (e) {
|
|
3396
3401
|
if (e.code === 'login_required') return showLogin()
|
|
3397
3402
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3499,12 +3504,12 @@ function paintTopDot() {
|
|
|
3499
3504
|
}
|
|
3500
3505
|
|
|
3501
3506
|
// ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
|
|
3502
|
-
// From
|
|
3507
|
+
// From 700px up the sections sit in a rail on the left; under it, in a bottom
|
|
3503
3508
|
// bar with the rest under More. Hover the Needs you item (or long-press it on a
|
|
3504
3509
|
// phone, or press ↓/→ on it) for Quick look: the count and the five longest
|
|
3505
3510
|
// waits, with Allow / Deny on permission prompts (the Needs you page's own
|
|
3506
3511
|
// buttons, so the same checks apply and nothing is answered without a click).
|
|
3507
|
-
const railMode = () => window.matchMedia('(min-width:
|
|
3512
|
+
const railMode = () => window.matchMedia('(min-width: 700px)').matches
|
|
3508
3513
|
function paintNav(r = state.route || parseRoute()) {
|
|
3509
3514
|
const key = navKey(r.name)
|
|
3510
3515
|
document.querySelectorAll('.tabs a[data-route], .topright a[data-route], #moreMenu a[data-route]').forEach((a) => { const on = a.dataset.route === key; a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
|
|
@@ -3533,6 +3538,7 @@ function openMore() {
|
|
|
3533
3538
|
if (!m) return
|
|
3534
3539
|
closeQuickLook(); toggleStatus(false)
|
|
3535
3540
|
paintMoreDots()
|
|
3541
|
+
paintPush()
|
|
3536
3542
|
m.hidden = false
|
|
3537
3543
|
if (b) b.setAttribute('aria-expanded', 'true')
|
|
3538
3544
|
const first = m.querySelector('a.on') || m.querySelector('a')
|
|
@@ -3661,6 +3667,214 @@ function closeQuickLook(focusTab = false) {
|
|
|
3661
3667
|
window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
|
|
3662
3668
|
}
|
|
3663
3669
|
|
|
3670
|
+
// ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
|
|
3671
|
+
// The board is the first tab; a session opens as the one preview tab (italic)
|
|
3672
|
+
// that the next click replaces, and stays open once you send a Live message,
|
|
3673
|
+
// type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
|
|
3674
|
+
// middle-click opens it permanent straight away). Kept per device in
|
|
3675
|
+
// localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
|
|
3676
|
+
// (or a swipe) on a session page; from 700px the strip. A width change (fold,
|
|
3677
|
+
// Split View, rotation) only changes the CSS: the open set, the session and
|
|
3678
|
+
// where you were on the page stay.
|
|
3679
|
+
const tabsUi = { m: parseStored(localStorage.getItem(TABS_KEY)), active: null, route: null, permanent: null, html: { strip: '', row: '', pager: '' }, anchor: null, logs: new WeakMap(), quietUntil: 0, fetchT: 0, fetchedAt: 0, scrolls: {} }
|
|
3680
|
+
window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
|
|
3681
|
+
function setTabs(m) {
|
|
3682
|
+
if (m === tabsUi.m) return
|
|
3683
|
+
tabsUi.m = m
|
|
3684
|
+
try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
|
|
3685
|
+
}
|
|
3686
|
+
const tabSession = (id) => state.sessions.find((s) => s.id === id)
|
|
3687
|
+
const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
|
|
3688
|
+
function tabsOnRoute(r) {
|
|
3689
|
+
const id = r.name === 's' && r.arg ? String(r.arg) : null
|
|
3690
|
+
if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
|
|
3691
|
+
if (id) {
|
|
3692
|
+
const permanent = tabsUi.permanent === id
|
|
3693
|
+
setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
|
|
3694
|
+
}
|
|
3695
|
+
tabsUi.permanent = null
|
|
3696
|
+
const changed = tabsUi.active !== id || tabsUi.route !== r.name
|
|
3697
|
+
tabsUi.active = id; tabsUi.route = r.name
|
|
3698
|
+
document.body.dataset.route = r.name
|
|
3699
|
+
paintTabs()
|
|
3700
|
+
if (changed) scrollActiveTab()
|
|
3701
|
+
}
|
|
3702
|
+
function tabsAfterRender() {
|
|
3703
|
+
let m = tabsUi.m
|
|
3704
|
+
for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
|
|
3705
|
+
setTabs(m)
|
|
3706
|
+
paintTabs()
|
|
3707
|
+
const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
|
|
3708
|
+
tabsUi.restoreScroll = null
|
|
3709
|
+
if (back != null) window.scrollTo(0, back)
|
|
3710
|
+
noteAnchor()
|
|
3711
|
+
// the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
|
|
3712
|
+
if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
|
|
3713
|
+
}
|
|
3714
|
+
async function refreshTabSessions() {
|
|
3715
|
+
tabsUi.fetchedAt = Date.now()
|
|
3716
|
+
try {
|
|
3717
|
+
const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
|
|
3718
|
+
const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
|
|
3719
|
+
state.sessions = state.sessions.map((s) => (s.id !== tabsUi.active && fresh.has(s.id) ? fresh.get(s.id) : s)).concat([...fresh.values()].filter((s) => !state.sessions.some((x) => x.id === s.id)))
|
|
3720
|
+
paintTabs()
|
|
3721
|
+
} catch {}
|
|
3722
|
+
}
|
|
3723
|
+
function paintTabs() {
|
|
3724
|
+
const views = tabViews()
|
|
3725
|
+
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
3726
|
+
if (!strip) return
|
|
3727
|
+
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
3728
|
+
if (sh !== tabsUi.html.strip) {
|
|
3729
|
+
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
3730
|
+
const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
|
|
3731
|
+
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
3732
|
+
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
3733
|
+
if (had) { const el = strip.querySelector('[data-tab="' + CSS.escape(had) + '"], [data-tab-close="' + CSS.escape(had) + '"], [data-tab-keep="' + CSS.escape(had) + '"], #' + CSS.escape(had)); if (el) el.focus({ preventScroll: true }) }
|
|
3734
|
+
}
|
|
3735
|
+
strip.hidden = false
|
|
3736
|
+
const rh = openRowHtml(views, esc)
|
|
3737
|
+
if (rh !== tabsUi.html.row) { const c = row.querySelector('.openrow-chips'), l = c ? c.scrollLeft : 0; row.innerHTML = rh; tabsUi.html.row = rh; const nc = row.querySelector('.openrow-chips'); if (nc) nc.scrollLeft = l }
|
|
3738
|
+
row.hidden = !rh
|
|
3739
|
+
const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
|
|
3740
|
+
if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
|
|
3741
|
+
pager.hidden = !ph
|
|
3742
|
+
}
|
|
3743
|
+
function scrollActiveTab() {
|
|
3744
|
+
const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
|
|
3745
|
+
if (!el || !sc) return
|
|
3746
|
+
const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
|
|
3747
|
+
if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
|
|
3748
|
+
else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
|
|
3749
|
+
}
|
|
3750
|
+
function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3751
|
+
function closeTabNow(id) {
|
|
3752
|
+
const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
|
|
3753
|
+
setTabs(model)
|
|
3754
|
+
delete tabsUi.scrolls[id]
|
|
3755
|
+
if (tabsUi.active === String(id)) {
|
|
3756
|
+
const t = next && model.tabs.find((x) => x.id === next)
|
|
3757
|
+
if (t) tabsUi.restoreScroll = t.id
|
|
3758
|
+
location.hash = t ? tabView(t).href : '#/'
|
|
3759
|
+
} else paintTabs()
|
|
3760
|
+
}
|
|
3761
|
+
function goTab(href) {
|
|
3762
|
+
const id = sessionOfHash(href)
|
|
3763
|
+
if (id) tabsUi.restoreScroll = id
|
|
3764
|
+
if (location.hash === href) render(); else location.hash = href
|
|
3765
|
+
}
|
|
3766
|
+
// ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
|
|
3767
|
+
function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
|
|
3768
|
+
document.addEventListener('click', (e) => {
|
|
3769
|
+
const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
|
|
3770
|
+
if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
|
|
3771
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3772
|
+
e.preventDefault(); e.stopPropagation()
|
|
3773
|
+
if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
|
|
3774
|
+
if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
|
|
3775
|
+
}, true)
|
|
3776
|
+
document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
|
|
3777
|
+
document.addEventListener('auxclick', (e) => {
|
|
3778
|
+
if (e.button !== 1 || !e.target.closest) return
|
|
3779
|
+
const cell = e.target.closest('#tabStrip .tab-cell')
|
|
3780
|
+
if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
|
|
3781
|
+
const a = e.target.closest('a[href^="#/s/"]')
|
|
3782
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3783
|
+
e.preventDefault()
|
|
3784
|
+
if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3785
|
+
else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
|
|
3786
|
+
})
|
|
3787
|
+
document.addEventListener('click', (e) => {
|
|
3788
|
+
if (!e.target.closest) return
|
|
3789
|
+
const close = e.target.closest('[data-tab-close]')
|
|
3790
|
+
if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
|
|
3791
|
+
const keep = e.target.closest('[data-tab-keep]')
|
|
3792
|
+
if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
|
|
3793
|
+
const go = e.target.closest('[data-tab-go]')
|
|
3794
|
+
if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
|
|
3795
|
+
const all = e.target.closest('#tabAll')
|
|
3796
|
+
if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
|
|
3797
|
+
const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
|
|
3798
|
+
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
3799
|
+
})
|
|
3800
|
+
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
3801
|
+
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
3802
|
+
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
3803
|
+
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
3804
|
+
document.addEventListener('click', (e) => { if (e.target.closest && $app.contains(e.target) && e.target.closest('[data-action="live-send"], .live-box [data-action], .need [data-choice], [data-action="ask-answer"], [data-action="rename"]')) promoteActive() }, true)
|
|
3805
|
+
// Drag a tab to reorder
|
|
3806
|
+
{
|
|
3807
|
+
let dragId = null
|
|
3808
|
+
document.addEventListener('dragstart', (e) => { const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; dragId = c.dataset.tabId; c.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId) } catch {} })
|
|
3809
|
+
document.addEventListener('dragover', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; e.preventDefault(); document.querySelectorAll('#tabStrip .drop-before').forEach((x) => x.classList.remove('drop-before')); c.classList.add('drop-before') })
|
|
3810
|
+
document.addEventListener('drop', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (c) { e.preventDefault(); const to = indexOf(tabsUi.m, c.dataset.tabId); const from = indexOf(tabsUi.m, dragId); setTabs(moveTab(tabsUi.m, dragId, from < to ? to - 1 : to)); paintTabs() } })
|
|
3811
|
+
document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
|
|
3812
|
+
}
|
|
3813
|
+
// Phones: swipe left / right on a session page for the next / previous open session
|
|
3814
|
+
{
|
|
3815
|
+
let sw = null
|
|
3816
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
3817
|
+
const scrollsSideways = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 2 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true } return false }
|
|
3818
|
+
document.addEventListener('touchstart', (e) => {
|
|
3819
|
+
sw = null
|
|
3820
|
+
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
3821
|
+
const t = e.target
|
|
3822
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
3823
|
+
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
3824
|
+
}, { passive: true })
|
|
3825
|
+
document.addEventListener('touchend', (e) => {
|
|
3826
|
+
if (!sw || !e.changedTouches.length) return
|
|
3827
|
+
const p = e.changedTouches[0]
|
|
3828
|
+
const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
|
|
3829
|
+
sw = null
|
|
3830
|
+
if (!dir || (window.getSelection && String(window.getSelection()))) return
|
|
3831
|
+
const n = neighbours(tabsUi.m, tabsUi.active)
|
|
3832
|
+
const to = dir === 'next' ? n.next : n.prev
|
|
3833
|
+
if (!to) return
|
|
3834
|
+
const t = tabsUi.m.tabs.find((x) => x.id === to)
|
|
3835
|
+
document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
|
|
3836
|
+
document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
|
|
3837
|
+
goTab(tabView(t).href)
|
|
3838
|
+
}, { passive: true })
|
|
3839
|
+
}
|
|
3840
|
+
// A width change keeps your place: the element at the top of the page stays at
|
|
3841
|
+
// the top, and a Live chat that was at its newest stays there.
|
|
3842
|
+
function anchorY() {
|
|
3843
|
+
let y = 0
|
|
3844
|
+
for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
|
|
3845
|
+
if (!el) continue
|
|
3846
|
+
const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
|
|
3847
|
+
const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
|
|
3848
|
+
}
|
|
3849
|
+
return y
|
|
3850
|
+
}
|
|
3851
|
+
function noteAnchor() {
|
|
3852
|
+
if (Date.now() < tabsUi.quietUntil) return
|
|
3853
|
+
const y = anchorY() + 6, m = $app.getBoundingClientRect()
|
|
3854
|
+
if (m.bottom < y) { tabsUi.anchor = null; return }
|
|
3855
|
+
let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
|
|
3856
|
+
if (!el || !$app.contains(el) || el === $app) { const kids = [...$app.querySelectorAll('h1, h2, h3, p, li, .card, .fact, .lv-msg, section, div')]; el = kids.find((k) => { const r = k.getBoundingClientRect(); return r.bottom > y && r.height > 0 }) || null }
|
|
3857
|
+
tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
|
|
3858
|
+
}
|
|
3859
|
+
function keepPlace() {
|
|
3860
|
+
const a = tabsUi.anchor
|
|
3861
|
+
tabsUi.quietUntil = Date.now() + 250
|
|
3862
|
+
if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
|
|
3863
|
+
document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
|
|
3864
|
+
}
|
|
3865
|
+
{
|
|
3866
|
+
let raf = 0
|
|
3867
|
+
window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
|
|
3868
|
+
document.addEventListener('scroll', (e) => { const log = e.target; if (log && log.classList && log.classList.contains('live-log') && Date.now() >= tabsUi.quietUntil) tabsUi.logs.set(log, { bottom: log.scrollHeight - log.scrollTop - log.clientHeight < 24 }) }, true)
|
|
3869
|
+
let lay = layoutFor(innerWidth)
|
|
3870
|
+
window.addEventListener('resize', () => {
|
|
3871
|
+
keepPlace()
|
|
3872
|
+
const now = layoutFor(innerWidth)
|
|
3873
|
+
if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
|
|
3874
|
+
})
|
|
3875
|
+
document.body.dataset.layout = lay
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3664
3878
|
// Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
|
|
3665
3879
|
// past the line; the page reads its data again, the same as a live update (no
|
|
3666
3880
|
// full reload unless that fails). Never from a field, the Live box or a menu.
|
|
@@ -3669,7 +3883,7 @@ function closeQuickLook(focusTab = false) {
|
|
|
3669
3883
|
ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
|
|
3670
3884
|
ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
|
|
3671
3885
|
document.body.appendChild(ptr)
|
|
3672
|
-
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width:
|
|
3886
|
+
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
3673
3887
|
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
3674
3888
|
let pull = null, refreshing = false
|
|
3675
3889
|
const paint = (step) => {
|
|
@@ -4973,3 +5187,6 @@ connect()
|
|
|
4973
5187
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
|
4974
5188
|
// when the Mac can't be reached; it caches no API data and no board pages.
|
|
4975
5189
|
if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
|
|
5190
|
+
// 0.9.8: phone notifications. The one per-device button (More on a phone,
|
|
5191
|
+
// Settings → General everywhere); the service worker shows the pushes.
|
|
5192
|
+
mountPush({ api })
|
package/ui/index.html
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
<a href="#/digest" data-route="digest" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
|
|
47
47
|
<a href="#/whats-new" data-route="whats-new" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span>What’s new</span><span class="newdot" hidden></span></a>
|
|
48
48
|
<a href="#/settings" data-route="settings" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span>Settings</span><span class="topdot" hidden></span></a>
|
|
49
|
+
<div class="push-slot" data-push-slot role="group" aria-label="Notifications" hidden></div>
|
|
49
50
|
</div>
|
|
50
51
|
<div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
|
|
51
52
|
<div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
|
|
@@ -56,6 +57,9 @@
|
|
|
56
57
|
<div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
|
|
57
58
|
</div>
|
|
58
59
|
</div>
|
|
60
|
+
<nav class="tabstrip" id="tabStrip" aria-label="Open sessions" hidden></nav>
|
|
61
|
+
<div class="openrow" id="openRow" role="navigation" aria-label="Open sessions" hidden></div>
|
|
62
|
+
<div class="tabpager" id="tabPager" role="navigation" aria-label="Open sessions" hidden></div>
|
|
59
63
|
<main id="app"></main>
|
|
60
64
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
61
65
|
<script src="relay-client.js" type="module"></script>
|
package/ui/live-status.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// └─► failed └─► stopped (the turn ended with no answer)
|
|
13
13
|
// received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
|
|
14
14
|
// something happens again; replied and failed are final.
|
|
15
|
+
// A message that arrived while Claude was already working on another one is
|
|
16
|
+
// folded into that turn (folded: the id of the message that owns the turn):
|
|
17
|
+
// it has no Working bubble of its own, and when the turn ends it is
|
|
18
|
+
// "Answered with the reply above".
|
|
15
19
|
|
|
16
20
|
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
17
21
|
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
@@ -22,7 +26,7 @@ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, '
|
|
|
22
26
|
export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
|
|
23
27
|
|
|
24
28
|
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
25
|
-
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended
|
|
29
|
+
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
|
|
26
30
|
export function stepStatus(st, ev = {}) {
|
|
27
31
|
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
28
32
|
const at = ev.at || s.last
|
|
@@ -43,6 +47,8 @@ export function stepStatus(st, ev = {}) {
|
|
|
43
47
|
case 'ask_done': s.waiting = null; break
|
|
44
48
|
case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
|
|
45
49
|
case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
|
|
50
|
+
case 'folded': if (s.phase === 'sent' || s.phase === 'received') s.phase = 'working'; s.folded = ev.owner || true; s.tool = null; s.waiting = null; break
|
|
51
|
+
case 'answered': s.phase = 'replied'; s.folded = ev.owner || s.folded || true; s.tool = null; s.waiting = null; s.endedAt = at; break // folded: the reply above is its answer
|
|
46
52
|
case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
|
|
47
53
|
default: return s
|
|
48
54
|
}
|
|
@@ -53,22 +59,23 @@ export function stepStatus(st, ev = {}) {
|
|
|
53
59
|
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
54
60
|
export function statusView(st, now = Date.now()) {
|
|
55
61
|
if (!st) return null
|
|
56
|
-
const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
62
|
+
const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
57
63
|
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
58
64
|
let label
|
|
59
65
|
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
60
66
|
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
67
|
+
else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
|
|
61
68
|
else if (st.phase === 'working') {
|
|
62
69
|
if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
|
|
63
70
|
else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
|
|
64
71
|
else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
|
|
65
|
-
} else if (st.phase === 'replied') label = 'Replied'
|
|
72
|
+
} else if (st.phase === 'replied') label = st.folded ? 'Answered with the reply above' : 'Replied'
|
|
66
73
|
else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
|
|
67
74
|
else label = 'Didn’t arrive'
|
|
68
75
|
const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
|
|
69
76
|
const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
|
|
70
77
|
.map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
|
|
71
|
-
const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
78
|
+
const spin = !quiet && !st.folded && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
|
|
72
79
|
const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
|
|
73
80
|
return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
|
|
74
81
|
}
|