@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/ui/live-view.js CHANGED
@@ -176,6 +176,21 @@ export function earlierHtml(n, { esc }) {
176
176
  if (!(n > 0)) return ''
177
177
  return '<li class="lv-earlier" data-key="earlier" data-sig="' + esc(String(n)) + '" data-older="1"><button type="button" class="btn live-earlier" data-action="live-earlier">Show earlier <span class="faint">(' + esc(String(n)) + (n === 1 ? ' turn' : ' turns') + ')</span></button></li>'
178
178
  }
179
+ // A message's time is when it was sent (a clock time today, the date before):
180
+ // a relative "just now" drawn once stays "just now" while the bubble sits there.
181
+ export function sentLabel(iso, now = Date.now()) {
182
+ const t = iso ? Date.parse(iso) : NaN
183
+ if (!Number.isFinite(t)) return ''
184
+ const d = new Date(t), n = new Date(now)
185
+ const today = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
186
+ return today ? d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
187
+ }
188
+ function timeHtml(iso, { esc, now }) {
189
+ const label = sentLabel(iso, now)
190
+ if (!label) return ''
191
+ const full = new Date(Date.parse(iso)).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
192
+ return '<time class="faint" datetime="' + esc(new Date(Date.parse(iso)).toISOString()) + '" title="' + esc(full) + '">' + esc(label) + '</time>'
193
+ }
179
194
  export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null }) {
180
195
  const who = senderLabel(m)
181
196
  const title = m.from === 'you' ? senderTitle(m) : ''
@@ -197,7 +212,7 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null })
197
212
  foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
198
213
  }
199
214
  const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
200
- return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span></div>' + body + foot + '</li>'
215
+ return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
201
216
  }
202
217
 
203
218
  const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
@@ -208,6 +223,8 @@ export function deliveryHtml(st, { esc, error = null }) {
208
223
  if (!st) return ''
209
224
  if (st.phase === 'failed') return '<div class="lv-tick bad" role="status">' + esc(error || 'Didn’t arrive') + '</div>'
210
225
  const got = st.phase !== 'sent'
226
+ // folded into a turn Claude was already on: the reply above answers it too, no Working bubble of its own
227
+ if (st.folded) return '<div class="lv-tick got lv-folded" role="status" title="It arrived while Claude was working, so Claude took it into that turn">Received <span aria-hidden="true">✓✓</span> · ' + esc(st.phase === 'replied' ? 'Answered with the reply above' : 'Taken into the turn above') + '</div>'
211
228
  return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
212
229
  }
213
230
 
@@ -255,8 +272,9 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
255
272
  const key = 'ans_' + you.id + ':claude'
256
273
  let inner
257
274
  if (st.phase === 'stopped') inner = '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>' + toolsHtml(st, { esc, now })
275
+ else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
258
276
  else inner = workingHtml(st, { esc, now })
259
- return '<li class="lv lv-claude lv-pending' + (st.phase === 'stopped' ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude <span class="faint">' + esc(ago(st.last ? new Date(st.last).toISOString() : you.at)) + '</span></div>' + inner + '</li>'
277
+ return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
260
278
  }
261
279
 
262
280
  // The whole thread: each message you sent, then Claude's side of that turn.
@@ -268,7 +286,7 @@ export function threadHtml(msgs, opts) {
268
286
  for (const m of list) {
269
287
  if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); continue }
270
288
  html += entryHtml(m, opts)
271
- if (m.status && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
289
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
272
290
  }
273
291
  return html
274
292
  }
@@ -290,7 +308,7 @@ export { ranWithoutAsking }
290
308
  // A content signature, to replace only the entries that changed (the time-ago
291
309
  // text is left out so a minute ticking over doesn't redraw a reply).
292
310
  function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
293
- export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt], m.done, m.ranWithoutAsking, m.errorText]) }
311
+ export function entrySig(m) { return JSON.stringify([m.text, m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
294
312
 
295
313
  // ------------------------------------------------------------ the DOM keeper
296
314
 
package/ui/push.js ADDED
@@ -0,0 +1,140 @@
1
+ // Phone notifications (0.9.8): the one per-device button. No setting: the
2
+ // button and the browser's own permission prompt are the opt-in, and the same
3
+ // button turns them off for this device. The Mac that owns a session sends
4
+ // the push (src/push.js); this file only subscribes this browser.
5
+ //
6
+ // iPhone: Web Push works for a board added to the Home Screen (iOS 16.4+), so
7
+ // Safari in a tab gets one line saying how, and the button appears in the
8
+ // Home Screen app. Pure parts (pushSupport, pushUi) are shared with the tests.
9
+
10
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
11
+ const BELL = '<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="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>'
12
+
13
+ export const isIos = (ua = '', touch = 0) => /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && touch > 1)
14
+
15
+ // What this browser can do -> { state } : insecure | add_to_home | unsupported | denied | off
16
+ export function pushSupport({ ua = '', touch = 0, standalone = false, hasPush = false, hasSW = false, secure = true, permission = 'default' } = {}) {
17
+ const ios = isIos(ua, touch)
18
+ if (!secure) return { state: 'insecure', ios }
19
+ if (ios && !standalone) return { state: 'add_to_home', ios }
20
+ if (!hasPush || !hasSW) return { state: 'unsupported', ios }
21
+ if (permission === 'denied') return { state: 'denied', ios }
22
+ return { state: 'off', ios }
23
+ }
24
+
25
+ // The block for a slot. st: pushSupport() plus { on, busy, note, noteBad }
26
+ export function pushUi(st = {}) {
27
+ const note = st.note ? '<p class="push-note' + (st.noteBad ? ' bad' : '') + '" role="status">' + esc(st.note) + '</p>' : ''
28
+ const busy = st.busy ? ' disabled aria-busy="true"' : ''
29
+ const wrap = (inner) => '<div class="push-box">' + BELL + '<div class="push-main">' + inner + note + '</div></div>'
30
+ switch (st.on ? 'on' : st.state) {
31
+ case 'add_to_home': return wrap('<p class="push-line">Add Leftoff to your Home Screen first (Share → Add to Home Screen), then turn on notifications here.</p>')
32
+ case 'denied': return wrap('<p class="push-line">Notifications are blocked for Leftoff on this device. Allow them in ' + (st.ios ? 'Settings → Notifications → Leftoff' : 'the browser’s site settings') + ', then come back.</p>')
33
+ case 'on': return wrap('<p class="push-line"><strong>Notifications on for this device</strong></p><div class="push-acts"><button type="button" class="btn" data-push="test"' + busy + '>Send a test</button><button type="button" class="btn ghost" data-push="off"' + busy + '>Turn off</button></div>')
34
+ case 'off': return wrap('<p class="push-line">Get a notification when Claude needs you or a Live reply lands.</p><div class="push-acts"><button type="button" class="btn primary" data-push="on"' + busy + '>' + (st.busy ? 'Turning on…' : 'Turn on notifications') + '</button></div>')
35
+ default: return '' // no Push here (or not https): nothing to offer
36
+ }
37
+ }
38
+
39
+ const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
40
+ const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)), (c) => c.charCodeAt(0))
41
+
42
+ const ui = { st: null, on: false, sub: null, busy: false, note: '', noteBad: false, api: null, refreshed: false }
43
+
44
+ function env() {
45
+ return {
46
+ ua: navigator.userAgent, touch: navigator.maxTouchPoints || 0,
47
+ standalone: !!(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true,
48
+ hasPush: 'PushManager' in window && 'Notification' in window, hasSW: 'serviceWorker' in navigator,
49
+ secure: !!window.isSecureContext, permission: 'Notification' in window ? Notification.permission : 'default',
50
+ }
51
+ }
52
+
53
+ async function registration() {
54
+ if (!('serviceWorker' in navigator)) return null
55
+ return (await navigator.serviceWorker.getRegistration('/')) || (await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => null))
56
+ }
57
+
58
+ const keyOf = (sub) => { try { const k = sub.options && sub.options.applicationServerKey; return k ? b64u(k) : null } catch { return null } }
59
+
60
+ async function load() {
61
+ const e = env()
62
+ ui.st = pushSupport(e)
63
+ ui.sub = null; ui.on = false
64
+ if (ui.st.state !== 'off') return
65
+ try { const reg = await registration(); ui.sub = reg && reg.pushManager ? await reg.pushManager.getSubscription() : null } catch { ui.sub = null }
66
+ ui.on = !!ui.sub && e.permission === 'granted'
67
+ // Once a page load: tell this Mac again (it shares with your other Macs), so a
68
+ // Mac that lost track, or a new one, knows this device.
69
+ if (ui.on && !ui.refreshed && ui.api) { ui.refreshed = true; ui.api('POST', '/v1/push/subscribe', { subscription: ui.sub.toJSON(), serverKey: keyOf(ui.sub), ua: navigator.userAgent }).catch(() => {}) }
70
+ }
71
+
72
+ export function paintPush() {
73
+ const slots = document.querySelectorAll('[data-push-slot]')
74
+ if (!slots.length) return
75
+ const html = ui.st ? pushUi({ ...ui.st, on: ui.on, busy: ui.busy, note: ui.note, noteBad: ui.noteBad }) : ''
76
+ for (const el of slots) { el.innerHTML = html; el.hidden = !html; el.setAttribute('data-pp', '1') }
77
+ }
78
+
79
+ async function turnOn() {
80
+ // The permission prompt first, straight from the tap (iOS asks only then).
81
+ const perm = await Notification.requestPermission()
82
+ if (perm !== 'granted') { ui.st = pushSupport(env()); ui.note = perm === 'denied' ? '' : 'Notifications weren’t allowed. Tap again to try.'; return }
83
+ await registration()
84
+ const reg = await navigator.serviceWorker.ready
85
+ const info = await ui.api('GET', '/v1/push')
86
+ let sub = await reg.pushManager.getSubscription()
87
+ // An existing subscription stays (the Mac that made it shares its key); a new one uses this Mac's key.
88
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: unb64u(info.publicKey) })
89
+ await ui.api('POST', '/v1/push/subscribe', { subscription: sub.toJSON(), serverKey: keyOf(sub) || info.publicKey, ua: navigator.userAgent })
90
+ ui.sub = sub; ui.on = true; ui.refreshed = true
91
+ ui.note = 'You’ll get a notification when Claude needs you or a Live reply lands.'
92
+ }
93
+
94
+ async function turnOff() {
95
+ const sub = ui.sub
96
+ if (sub) {
97
+ const endpoint = sub.endpoint
98
+ try { await sub.unsubscribe() } catch {}
99
+ await ui.api('POST', '/v1/push/unsubscribe', { endpoint }).catch(() => {})
100
+ }
101
+ ui.sub = null; ui.on = false
102
+ ui.note = 'Notifications off for this device.'
103
+ }
104
+
105
+ async function sendTest() {
106
+ const r = await ui.api('POST', '/v1/push/test', { endpoint: ui.sub && ui.sub.endpoint })
107
+ if (r && r.ok) { ui.note = 'Sent from ' + (r.machine || 'your Mac') + '. It should show up in a few seconds.'; return }
108
+ if (r && r.error === 'not_subscribed') { ui.on = false; ui.sub = null; throw new Error('This Mac didn’t know this device. Turn notifications on again.') }
109
+ throw new Error('The push service didn’t take it' + (r && r.error ? ' (' + r.error + ')' : '') + '.')
110
+ }
111
+
112
+ // Wire it once: api is app.js's api(method, path, body).
113
+ export function mountPush({ api }) {
114
+ ui.api = api
115
+ document.addEventListener('click', async (ev) => {
116
+ const b = ev.target.closest && ev.target.closest('[data-push]')
117
+ if (!b || ui.busy) return
118
+ ev.preventDefault(); ev.stopPropagation()
119
+ const what = b.getAttribute('data-push')
120
+ ui.busy = true; ui.note = ''; ui.noteBad = false; paintPush()
121
+ try {
122
+ if (what === 'on') await turnOn()
123
+ else if (what === 'off') await turnOff()
124
+ else if (what === 'test') await sendTest()
125
+ } catch (e) { ui.note = e && e.message ? e.message : String(e); ui.noteBad = true }
126
+ ui.busy = false
127
+ paintPush()
128
+ })
129
+ load().then(paintPush, paintPush)
130
+ // Settings is drawn on demand: paint a slot the moment one appears.
131
+ const app = document.getElementById('app')
132
+ if (app && window.MutationObserver) new MutationObserver(() => { if (document.querySelector('[data-push-slot]:not([data-pp])')) paintPush() }).observe(app, { childList: true, subtree: true })
133
+ // A tapped notification: the service worker asks the open board to show that session.
134
+ if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', (ev) => {
135
+ const d = ev.data || {}
136
+ if (d.type !== 'leftoff-open' || typeof d.url !== 'string') return
137
+ try { const u = new URL(d.url, location.origin); if (u.origin === location.origin && location.hash !== u.hash) location.hash = u.hash } catch {}
138
+ })
139
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !ui.busy) load().then(paintPush, paintPush) })
140
+ }
package/ui/sw.js CHANGED
@@ -1,8 +1,13 @@
1
- // Leftoff's service worker (v0.4.6). One job: when the Mac running Leftoff
2
- // can't be reached (Tailscale off on the phone, the Mac asleep, the daemon
3
- // stopped), a page navigation shows offline.html instead of Safari's error.
4
- // It caches nothing else: no API data, no board pages, no assets. Every other
5
- // request goes straight to the network, untouched.
1
+ // Leftoff's service worker (v0.4.6). Two jobs:
2
+ // 1. When the Mac running Leftoff can't be reached (Tailscale off on the
3
+ // phone, the Mac asleep, the daemon stopped), a page navigation shows
4
+ // offline.html instead of Safari's error. It caches nothing else: no API
5
+ // data, no board pages, no assets. Every other request goes straight to the
6
+ // network, untouched.
7
+ // 2. (0.9.8) Phone notifications: a push from the Mac that owns a session
8
+ // becomes a notification (the session's title, one short line, a tag per
9
+ // session and kind so a burst replaces the last one), and a tap opens that
10
+ // session (its Live chat, or the permission card at the top).
6
11
  const CACHE = 'leftoff-offline-v1'
7
12
  const OFFLINE = ['/offline.html', '/offline.css', '/icon-192.png']
8
13
 
@@ -45,3 +50,45 @@ function slow(url) {
45
50
  function offline() {
46
51
  return caches.match('/offline.html').then((r) => r || new Response('Leftoff is offline', { status: 503, headers: { 'content-type': 'text/plain' } }))
47
52
  }
53
+
54
+ // ------------------------------------------------------------ notifications (0.9.8)
55
+
56
+ // Safari must show every push (it takes the permission back after a few
57
+ // silent ones), so only other browsers skip one for the session already on screen.
58
+ const MAY_SKIP = /Chrome|CriOS|Firefox|FxiOS|Edg\//.test(self.navigator.userAgent || '')
59
+ const sameSession = (clientUrl, url) => {
60
+ try { const a = new URL(clientUrl).hash, b = new URL(url, self.location.origin).hash; return !!b && b !== '#/' && (a === b || a.startsWith(b + '/') || a.startsWith(b + '?')) } catch { return false }
61
+ }
62
+ const windows = () => self.clients.matchAll({ type: 'window', includeUncontrolled: true })
63
+
64
+ self.addEventListener('push', (event) => {
65
+ let d = {}
66
+ try { d = event.data ? event.data.json() : {} } catch { d = { body: event.data ? event.data.text() : '' } }
67
+ const url = typeof d.url === 'string' && d.url.startsWith('/') ? d.url : '/'
68
+ event.waitUntil((async () => {
69
+ if (MAY_SKIP && d.kind !== 'test') {
70
+ const on = (await windows()).some((c) => c.focused && c.visibilityState === 'visible' && sameSession(c.url, url))
71
+ if (on) return
72
+ }
73
+ await self.registration.showNotification(String(d.title || 'Leftoff').slice(0, 80), {
74
+ body: String(d.body || '').slice(0, 180), tag: d.tag || undefined, renotify: !!d.tag, icon: '/icon-192.png',
75
+ data: { url, kind: d.kind || null, sessionId: d.sessionId || null }, timestamp: Date.now(),
76
+ })
77
+ })())
78
+ })
79
+
80
+ self.addEventListener('notificationclick', (event) => {
81
+ event.notification.close()
82
+ const url = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href
83
+ event.waitUntil((async () => {
84
+ const list = (await windows()).filter((c) => new URL(c.url).origin === self.location.origin)
85
+ const c = list.find((x) => x.focused) || list[0]
86
+ if (c) {
87
+ // the board routes by hash: tell the page (no reload), then bring it forward
88
+ c.postMessage({ type: 'leftoff-open', url })
89
+ try { await c.focus() } catch {}
90
+ return
91
+ }
92
+ await self.clients.openWindow(url)
93
+ })())
94
+ })
package/ui/tabs.js ADDED
@@ -0,0 +1,216 @@
1
+ // Tabs for sessions (0.9.8). The board is always the first tab and can't be
2
+ // closed. Clicking a session opens it in ONE preview tab (its title in italics)
3
+ // that the next click replaces, VS Code style; it stays open (permanent, with a
4
+ // close ×) once you send a Live message in it, type there, choose Keep open or
5
+ // double-click the tab, and a middle-click or ⌘/Ctrl-click opens a session
6
+ // straight as permanent. The open set and its order are kept per device
7
+ // (localStorage) and survive a reload; a deep link opens as the preview.
8
+ //
9
+ // The layout follows the window's WIDTH, never the device: under 700px no strip
10
+ // (an Open row on the board, swipe or ‹ › between open sessions on a session
11
+ // page); 700–1099px the strip with the rail as icons; from 1100px the full rail
12
+ // and the strip. Pure functions over a plain model; app.js wires them.
13
+
14
+ export const TABS_KEY = 'cp.tabs'
15
+ export const MAX_TABS = 40
16
+ export const NARROW_MAX = 699.98 // < 700: phones, a foldable closed
17
+ export const WIDE_MIN = 1100 // ≥ 1100: iPad landscape, a Mac
18
+
19
+ // 'narrow' | 'medium' | 'wide' for a CSS width
20
+ export function layoutFor(width) {
21
+ const w = Number(width) || 0
22
+ return w < 700 ? 'narrow' : w < WIDE_MIN ? 'medium' : 'wide'
23
+ }
24
+
25
+ const clean = (e) => ({ id: String(e.id), title: typeof e.title === 'string' ? e.title.slice(0, 200) : '', preview: !!e.preview, ...(typeof e.hash === 'string' && e.hash.startsWith('#/s/') ? { hash: e.hash } : {}) })
26
+
27
+ // Any stored or hand-made value → a valid model: string ids, no repeats, at most
28
+ // one preview (the last one wins), at most MAX_TABS.
29
+ export function normalize(raw) {
30
+ const list = raw && Array.isArray(raw.tabs) ? raw.tabs : []
31
+ const seen = new Set(), tabs = []
32
+ for (const e of list) {
33
+ if (!e || e.id == null || e.id === '' || seen.has(String(e.id))) continue
34
+ seen.add(String(e.id)); tabs.push(clean(e))
35
+ }
36
+ let pi = -1
37
+ tabs.forEach((t, i) => { if (t.preview) { if (pi >= 0) tabs[pi].preview = false; pi = i } })
38
+ return { tabs: tabs.slice(-MAX_TABS) }
39
+ }
40
+ export function emptyTabs() { return { tabs: [] } }
41
+ export function parseStored(text) {
42
+ try { return normalize(JSON.parse(text || 'null')) } catch { return emptyTabs() }
43
+ }
44
+ export function serialize(m) { return JSON.stringify({ v: 1, tabs: normalize(m).tabs }) }
45
+
46
+ export const indexOf = (m, id) => m.tabs.findIndex((t) => t.id === String(id))
47
+ export const isOpen = (m, id) => indexOf(m, id) >= 0
48
+ export const previewOf = (m) => m.tabs.find((t) => t.preview) || null
49
+
50
+ // Open a session. Already open: it just gets focus (permanent promotes a preview).
51
+ // Not open: as the preview, replacing the old preview in place; or, permanent,
52
+ // appended at the end.
53
+ export function openTab(m, id, { permanent = false, title, hash } = {}) {
54
+ const tabs = m.tabs.map((t) => ({ ...t }))
55
+ id = String(id)
56
+ const i = tabs.findIndex((t) => t.id === id)
57
+ const extra = { ...(title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') ? { hash } : {}) }
58
+ if (i >= 0) {
59
+ Object.assign(tabs[i], extra)
60
+ if (permanent) tabs[i].preview = false
61
+ return { tabs }
62
+ }
63
+ const e = { id, title: '', preview: !permanent, ...extra }
64
+ const pi = tabs.findIndex((t) => t.preview)
65
+ if (!permanent && pi >= 0) tabs[pi] = e
66
+ else tabs.push(e)
67
+ // too many: the oldest permanent tab that isn't this one goes
68
+ while (tabs.length > MAX_TABS) { const k = tabs.findIndex((t) => t.id !== id); tabs.splice(k, 1) }
69
+ return { tabs }
70
+ }
71
+
72
+ // The preview becomes permanent (a Live message, typing there, Keep open, a double-click).
73
+ export function promote(m, id) {
74
+ const i = indexOf(m, id)
75
+ if (i < 0 || !m.tabs[i].preview) return m
76
+ return { tabs: m.tabs.map((t, k) => (k === i ? { ...t, preview: false } : t)) }
77
+ }
78
+
79
+ // Keep a tab's title (shown while its session isn't loaded) and where in it you were.
80
+ export function remember(m, id, { title, hash } = {}) {
81
+ const i = indexOf(m, id)
82
+ if (i < 0) return m
83
+ const t = m.tabs[i]
84
+ const nt = { ...t, ...(title && title !== t.title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') && hash !== t.hash ? { hash } : {}) }
85
+ if (nt.title === t.title && nt.hash === t.hash) return m
86
+ return { tabs: m.tabs.map((x, k) => (k === i ? nt : x)) }
87
+ }
88
+
89
+ // Close a tab. When it was the one you're on, `next` is where to go: the
90
+ // neighbour on the right, else the one on the left, else the board (null).
91
+ export function closeTab(m, id, activeId = null) {
92
+ const i = indexOf(m, id)
93
+ if (i < 0) return { model: m, next: activeId == null ? null : String(activeId) }
94
+ const tabs = m.tabs.filter((_, k) => k !== i)
95
+ let next = activeId == null ? null : String(activeId)
96
+ if (String(id) === next) next = tabs.length ? (tabs[i] || tabs[i - 1]).id : null
97
+ return { model: { tabs }, next }
98
+ }
99
+
100
+ // Move a tab to another position (drag on the strip).
101
+ export function moveTab(m, id, to) {
102
+ const i = indexOf(m, id)
103
+ if (i < 0) return m
104
+ const tabs = m.tabs.slice()
105
+ const [t] = tabs.splice(i, 1)
106
+ tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, t)
107
+ return { tabs }
108
+ }
109
+
110
+ // Where a session sits among the open ones (the phone's "2 of 4", swipe and ‹ ›).
111
+ export function neighbours(m, id) {
112
+ const i = indexOf(m, id)
113
+ if (i < 0) return { index: 0, count: m.tabs.length, prev: null, next: null }
114
+ return { index: i + 1, count: m.tabs.length, prev: i > 0 ? m.tabs[i - 1].id : null, next: i < m.tabs.length - 1 ? m.tabs[i + 1].id : null }
115
+ }
116
+
117
+ // The session id a hash opens (#/s/<id>, #/s/<id>/live, #/s/<id>?hit=…), or null.
118
+ export function sessionOfHash(hash) {
119
+ const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
120
+ const p = h.split('/').filter(Boolean)
121
+ if (p[0] !== 's' || !p[1]) return null
122
+ try { return decodeURIComponent(p[1]) } catch { return p[1] }
123
+ }
124
+ // Where a tab goes back to: its own page, keeping /live (not a search hit).
125
+ export function tabHref(t) {
126
+ const base = '#/s/' + encodeURIComponent(t.id)
127
+ if (t.hash && sessionOfHash(t.hash) === t.id && t.hash.split('?')[0].endsWith('/live')) return base + '/live'
128
+ return base
129
+ }
130
+ export function keepHash(hash) { return String(hash || '').split('?')[0] }
131
+
132
+ // What a tab shows for its session: the title, the Live pulse while Claude is
133
+ // working (the Live chip's own signal), a dot when it needs you (the same as
134
+ // Needs you). `session` may be missing (not loaded yet): the kept title, no marks.
135
+ export function tabView(t, session, { title, working } = {}) {
136
+ const s = session || null
137
+ return {
138
+ id: t.id, preview: !!t.preview, href: tabHref(t),
139
+ title: (s && title ? title(s) : '') || t.title || 'Session',
140
+ working: !!(s && working && working(s)),
141
+ needs: !!(s && s.needsYou && !s.archived),
142
+ }
143
+ }
144
+
145
+ const X = '<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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
146
+ const BOARD_IC = '<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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
147
+ const DOTS = '<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"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
148
+ const PREV = '<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 18-6-6 6-6"/></svg>'
149
+ const NEXT = '<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 18 6-6-6-6"/></svg>'
150
+
151
+ // The marks after a title: a pulsing Live dot, or the red needs-you dot (both when both).
152
+ function marks(v, esc) {
153
+ return (v.working ? '<span class="tab-live" title="Claude is working"><span class="lo-vh">, Claude is working</span></span>' : '') +
154
+ (v.needs ? '<span class="tab-need" title="Needs you"><span class="lo-vh">, needs you</span></span>' : '')
155
+ }
156
+ function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
157
+
158
+ // The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
159
+ // The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
160
+ export function stripHtml(views, activeId, onBoard, esc) {
161
+ const board = '<a class="tab tab-board' + (onBoard ? ' on' : '') + '" href="#/" data-tab="board"' + (onBoard ? ' aria-current="true"' : '') + ' title="Board">' + BOARD_IC + '<span class="tab-t">Board</span></a>'
162
+ const tabs = views.map((v) => {
163
+ const on = v.id === activeId
164
+ return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" data-tab-id="' + esc(v.id) + '">' +
165
+ '<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' title="' + esc(v.title + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc(label(v)) + '" draggable="true">' +
166
+ '<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
167
+ (v.preview
168
+ ? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" title="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '"><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="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg></button>'
169
+ : '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" title="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') +
170
+ '</div>'
171
+ }).join('')
172
+ return '<div class="tabstrip-in">' + board + '<div class="tab-scroll" role="list">' + tabs + '</div></div>' +
173
+ '<button type="button" class="tab-all" id="tabAll" title="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
174
+ }
175
+
176
+ // The menu behind ⋯ (picker.js's action menu): every open session, in order.
177
+ export function allTabsItems(views, activeId) {
178
+ return [{ label: 'Board', attrs: { 'data-tab-go': '#/' }, on: activeId == null }].concat(views.map((v) => ({
179
+ label: v.title + (v.preview ? ' (preview)' : ''), sub: [v.working ? 'Claude is working' : '', v.needs ? 'Needs you' : ''].filter(Boolean).join(' · ') || undefined,
180
+ on: v.id === activeId, attrs: { 'data-tab-go': v.href },
181
+ })))
182
+ }
183
+
184
+ // The phone's Open row on the board: the same order, the same marks, the preview in italics.
185
+ export function openRowHtml(views, esc) {
186
+ if (!views.length) return ''
187
+ return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
188
+ '<span class="ochip-cell' + (v.preview ? ' preview' : '') + '"><a class="ochip' + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '" aria-label="' + esc(label(v)) + '"><span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
189
+ (v.preview ? '' : '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '" title="Close">' + X + '</button>') + '</span>').join('') + '</div>'
190
+ }
191
+
192
+ // A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,
193
+ // and Keep open on a preview.
194
+ export function pagerHtml(m, id, views, esc) {
195
+ const n = neighbours(m, id)
196
+ if (!n.index || n.count < 2) {
197
+ const v = views.find((x) => x.id === String(id))
198
+ return v && v.preview ? '<div class="pager-in"><span class="pager-n faint">Preview</span><button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button></div>' : ''
199
+ }
200
+ const byId = (x) => views.find((v) => v.id === x)
201
+ const v = byId(String(id))
202
+ const btn = (to, dir) => {
203
+ const t = to ? byId(to) : null
204
+ return t ? '<a class="pager-b pager-' + dir + '" href="' + esc(t.href) + '" data-tab="' + esc(t.id) + '" data-pager="' + dir + '" aria-label="' + esc((dir === 'prev' ? 'Previous: ' : 'Next: ') + t.title) + '" title="' + esc(t.title) + '">' + (dir === 'prev' ? PREV : NEXT) + '</a>'
205
+ : '<span class="pager-b pager-' + dir + ' off" aria-hidden="true">' + (dir === 'prev' ? PREV : NEXT) + '</span>'
206
+ }
207
+ return '<div class="pager-in">' + btn(n.prev, 'prev') + '<span class="pager-n" aria-live="polite">' + n.index + ' of ' + n.count + '<span class="lo-vh"> open sessions</span></span>' +
208
+ (v && v.preview ? '<button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button>' : '') + btn(n.next, 'next') + '</div>'
209
+ }
210
+
211
+ // A horizontal swipe on a phone's session page: which way, or null. Mostly
212
+ // sideways (twice as far across as down), at least 60px, quick (under 700ms).
213
+ export function swipeDir(dx, dy, ms) {
214
+ if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy) || ms > 700) return null
215
+ return dx < 0 ? 'next' : 'prev'
216
+ }