@leftoff/cli 0.8.6 → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.8.6",
3
+ "version": "0.8.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/core.js CHANGED
@@ -298,7 +298,8 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
298
298
  export function resumeCommand(session) {
299
299
  const id = session.id || session.sessionId
300
300
  const cwd = resumeDir(session)
301
- return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : claudeInvocation(session, ['--resume', id]))
301
+ const wt = resumeWorktreeName(session)
302
+ return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : claudeInvocation(session, [...(wt ? ['--worktree', wt] : []), '--resume', id]))
302
303
  }
303
304
 
304
305
  // 0.8.0: a Codex session (its rollout under ~/.codex/sessions) resumes with `codex resume <id>`.
@@ -308,9 +309,24 @@ export function codexInvocation(id) { return 'codex resume ' + shArg(id) }
308
309
  // 0.7.1: where to run `claude --resume`: the session's folder, or, inside a git
309
310
  // worktree, that worktree's own top folder (Claude Code declines to re-enter a
310
311
  // `git worktree add` worktree from one of its subfolders; code.claude.com/docs/en/worktrees).
312
+ //
313
+ // 0.8.8: a worktree Claude Code made (<repo>/.claude/worktrees/<name>, from
314
+ // `claude --worktree` or EnterWorktree) is resumed the way Claude Code itself
315
+ // prints on exit: from the main checkout, `claude --worktree <name> --resume <id>`.
316
+ // On exit Claude Code moves the session back to where it was launched and
317
+ // clears its worktree binding, so --worktree is what puts it back in there.
318
+ // Not when the worktree is gone (folderGone): --worktree would make a new one.
319
+ export function resumeWorktreeName(session) {
320
+ const wt = session && session.repo && session.repo.worktree
321
+ const root = session && session.repo && session.repo.root
322
+ if (!wt || !root || typeof wt !== 'string' || session.folderGone || isCodex(session)) return null
323
+ const c = claudeWorktreeOf(wt)
324
+ return c && trimSlash(c.root) === trimSlash(root) && trimSlash(c.path) === trimSlash(wt) ? c.name : null
325
+ }
311
326
  export function resumeDir(session) {
312
327
  const cwd = session && session.cwd
313
328
  const wt = session && session.repo && session.repo.worktree
329
+ if (resumeWorktreeName(session)) return session.repo.root
314
330
  if (cwd && wt && typeof wt === 'string' && (cwd === wt || String(cwd).startsWith(wt.replace(/\/+$/, '') + '/'))) return wt
315
331
  return cwd || null
316
332
  }
@@ -821,9 +837,14 @@ export function applyEvent(s, ev) {
821
837
  // replayed older events (a Codex rollout read again) don't.
822
838
  if (s.parked && ms(at) > ms(s.parked.at) && unparks(s, ev, d)) delete s.parked
823
839
  switch (ev.type) {
824
- case 'session.parked':
840
+ case 'session.parked': {
841
+ const prevLock = s.parked && s.parked.worktreeLock // parked again: the lock it already holds stays on record
825
842
  s.parked = { at, note: d.note ? clip(String(d.note).replace(/\s+/g, ' ').trim(), 300) || null : null, checkpoint: d.checkpoint ? String(d.checkpoint) : null, by: d.by ? String(d.by) : null }
843
+ // 0.8.8: the worktree /cp-park (or Park) locked, so it can be unlocked later
844
+ if (d.worktreeLock && typeof d.worktreeLock === 'object' && typeof d.worktreeLock.path === 'string' && typeof d.worktreeLock.reason === 'string') s.parked.worktreeLock = { path: d.worktreeLock.path, root: typeof d.worktreeLock.root === 'string' ? d.worktreeLock.root : null, reason: d.worktreeLock.reason }
845
+ else if (prevLock) s.parked.worktreeLock = prevLock
826
846
  break
847
+ }
827
848
  case 'session.unparked':
828
849
  delete s.parked
829
850
  break
@@ -912,6 +933,137 @@ export function parseParkArgs(args) {
912
933
  return { note: note ? clip(note, 300) : null }
913
934
  }
914
935
 
936
+ // ------------------------------------------------------------ parked worktrees (0.8.8)
937
+ // Claude Code removes a session's worktree when the session exits and the
938
+ // worktree has no changes (code.claude.com/docs/en/worktrees, "Clean up
939
+ // worktrees"), and /cp-park exits. So in a linked git worktree, park first
940
+ // locks it (`git worktree lock --reason …`). Claude Code's exit cleanup keeps a
941
+ // worktree locked with a reason it didn't write, and its stale-lock sweep only
942
+ // releases its own "claude agent|session … (pid N)" locks, so the reason must
943
+ // never start with "claude ". Resume, Done with it, or the session coming back
944
+ // unlocks it. Only a lock with this exact session's reason is ever unlocked.
945
+ // run(argv) -> { code | exitCode, stdout, stderr }: $.process.run in the mod,
946
+ // execFile in the daemon, a fake in tests. Nothing here deletes anything.
947
+ export const PARK_LOCK_PREFIX = 'leftoff: parked session '
948
+ export function parkLockReason(sessionId) {
949
+ return PARK_LOCK_PREFIX + String(sessionId || '') + ' (resume it, or press Done with it on the board, to unlock)'
950
+ }
951
+ // a lock reason -> the session id Leftoff parked, or null for any other lock
952
+ export function parkLockSession(reason) {
953
+ const m = /^leftoff: parked session ([A-Za-z0-9-]{4,80})(?: |$)/.exec(String(reason == null ? '' : reason))
954
+ return m ? m[1] : null
955
+ }
956
+
957
+ // `git worktree list --porcelain` -> [{ path, branch, bare, detached, locked: null | reason ('' when none given) }]
958
+ export function parseWorktreeList(text) {
959
+ const out = []
960
+ let cur = null
961
+ for (const raw of String(text || '').split('\n')) {
962
+ const line = raw.replace(/\r$/, '')
963
+ if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, bare: false, detached: false, locked: null }; out.push(cur); continue }
964
+ if (!cur) continue
965
+ if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '')
966
+ else if (line === 'bare') cur.bare = true
967
+ else if (line === 'detached') cur.detached = true
968
+ else if (line === 'locked') cur.locked = ''
969
+ else if (line.startsWith('locked ')) cur.locked = line.slice(7)
970
+ }
971
+ return out
972
+ }
973
+
974
+ const runCode = (r) => (r == null ? null : r.code != null ? r.code : r.exitCode != null ? r.exitCode : null)
975
+ const trimSlash = (p) => String(p || '').replace(/\/+$/, '') || '/'
976
+ const firstLine = (s) => String(s || '').trim().split('\n')[0].slice(0, 200)
977
+ // a path inside Claude Code's own worktrees folder: <repo>/.claude/worktrees/<name>[/…]
978
+ export function claudeWorktreeOf(dir) {
979
+ const m = /^(.*)\/\.claude\/worktrees\/([^/]+)(?:\/.*)?$/.exec(trimSlash(dir))
980
+ return m ? { root: m[1] || '/', name: m[2], path: m[1] + '/.claude/worktrees/' + m[2] } : null
981
+ }
982
+
983
+ // Is dir in a linked git worktree? -> { kind: 'worktree', top, root, gitDir, commonDir }
984
+ // | { kind: 'none' } (not in git, or the main checkout) | { kind: 'unknown', why }
985
+ export async function worktreeAt(run, dir) {
986
+ if (!dir) return { kind: 'unknown', why: 'no folder on record' }
987
+ let r
988
+ try { r = await run(['git', '-C', dir, 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel']) } catch (e) { return { kind: 'unknown', why: 'git didn’t run (' + firstLine(e && e.message) + ')' } }
989
+ if (runCode(r) !== 0) {
990
+ if (/not a git repository/i.test(String(r && r.stderr))) return claudeWorktreeOf(dir) ? { kind: 'unknown', why: 'git doesn’t see a repository there' } : { kind: 'none' }
991
+ return { kind: 'unknown', why: 'git: ' + (firstLine(r && r.stderr) || 'exit ' + runCode(r)) }
992
+ }
993
+ const [gitDir, commonDir, top] = String(r.stdout || '').trim().split('\n').map((x) => trimSlash(x.trim()))
994
+ if (!gitDir || !commonDir || !top) return { kind: 'unknown', why: 'git gave no answer' }
995
+ if (gitDir === commonDir) return { kind: 'none' } // the main checkout (or a submodule, its own repo)
996
+ return { kind: 'worktree', top, gitDir, commonDir, root: /\/\.git$/.test(commonDir) ? commonDir.slice(0, -5) || '/' : commonDir }
997
+ }
998
+
999
+ function findEntry(list, path, reason) {
1000
+ const p = trimSlash(path)
1001
+ return list.find((e) => trimSlash(e.path) === p) || (reason ? list.find((e) => e.locked === reason) : null) || null
1002
+ }
1003
+
1004
+ // /cp-park (and Park on the board): lock the worktree dir is in, then read it
1005
+ // back. -> { worktree: null } (not a worktree: nothing to protect)
1006
+ // | { worktree, root, locked: true, reason } (locked and checked)
1007
+ // | { worktree, root?, locked: false, why } (not protected: don't exit for them)
1008
+ export async function lockForPark(run, dir, sessionId) {
1009
+ const w = await worktreeAt(run, dir)
1010
+ if (w.kind === 'none') return { worktree: null }
1011
+ if (w.kind === 'unknown') { const g = claudeWorktreeOf(dir); return { worktree: g ? g.path : null, locked: false, why: w.why, unsure: true } }
1012
+ const reason = parkLockReason(sessionId)
1013
+ let lockErr = ''
1014
+ try { const r = await run(['git', '-C', w.top, 'worktree', 'lock', '--reason', reason, w.top]); if (runCode(r) !== 0) lockErr = firstLine(r && r.stderr) || 'exit ' + runCode(r) } catch (e) { lockErr = firstLine(e && e.message) }
1015
+ let list = null
1016
+ try { const l = await run(['git', '-C', w.top, 'worktree', 'list', '--porcelain']); if (runCode(l) === 0) list = parseWorktreeList(l.stdout) } catch {}
1017
+ const base = { worktree: w.top, root: w.root }
1018
+ if (!list) return { ...base, locked: false, why: 'couldn’t read the lock back' + (lockErr ? ' (' + lockErr + ')' : '') }
1019
+ const e = findEntry(list, w.top, reason)
1020
+ if (e && e.locked === reason) return { ...base, locked: true, reason }
1021
+ if (e && e.locked != null) return { ...base, locked: false, why: 'it’s already locked' + (e.locked ? ' (“' + clip(e.locked, 80) + '”)' : ''), foreign: true }
1022
+ return { ...base, locked: false, why: lockErr || 'the lock didn’t take' }
1023
+ }
1024
+
1025
+ // Is the lock still there, with our reason? (checked again right before /exit)
1026
+ export async function parkLockHeld(run, path, reason) {
1027
+ try {
1028
+ const l = await run(['git', '-C', path, 'worktree', 'list', '--porcelain'])
1029
+ if (runCode(l) !== 0) return false
1030
+ const e = findEntry(parseWorktreeList(l.stdout), path, reason)
1031
+ return !!(e && e.locked === reason)
1032
+ } catch { return false }
1033
+ }
1034
+
1035
+ // Unlock what Leftoff locked for this session: every worktree of the repo at
1036
+ // dir whose lock reason is this session's park reason. Any other lock (yours,
1037
+ // Claude Code's) is left alone. -> { unlocked: [path], failed: [{ path, why }], error? }
1038
+ export async function unlockParkLocks(run, dir, sessionId) {
1039
+ const out = { unlocked: [], failed: [] }
1040
+ if (!dir || !sessionId) return out
1041
+ let list
1042
+ try {
1043
+ const l = await run(['git', '-C', dir, 'worktree', 'list', '--porcelain'])
1044
+ if (runCode(l) !== 0) return { ...out, error: firstLine(l && l.stderr) || 'git worktree list failed' }
1045
+ list = parseWorktreeList(l.stdout)
1046
+ } catch (e) { return { ...out, error: firstLine(e && e.message) } }
1047
+ for (const e of list) {
1048
+ if (e.locked == null || parkLockSession(e.locked) !== String(sessionId)) continue
1049
+ try {
1050
+ const r = await run(['git', '-C', dir, 'worktree', 'unlock', e.path])
1051
+ if (runCode(r) === 0) out.unlocked.push(e.path)
1052
+ else out.failed.push({ path: e.path, why: firstLine(r && r.stderr) || 'exit ' + runCode(r) })
1053
+ } catch (err) { out.failed.push({ path: e.path, why: firstLine(err && err.message) }) }
1054
+ }
1055
+ return out
1056
+ }
1057
+
1058
+ // What /cp-park says about the worktree. res: lockForPark's answer.
1059
+ export function parkWorktreeText(res) {
1060
+ if (!res || !res.worktree && !res.unsure) return ''
1061
+ if (!res.worktree && !res.locked) return ' Leftoff couldn’t tell whether this is a worktree session (' + (res.why || 'no answer') + '), so it didn’t close it. If it is one, lock it first (`git worktree lock <worktree>`) or choose Keep if Claude Code asks: on /exit, Claude Code removes a worktree that has no changes.'
1062
+ if (res.locked) return ' This is a worktree session, so Leftoff locked the worktree first: Claude Code will say it kept it (“could not be removed”). Resume or Done with it unlocks it.'
1063
+ const where = res.worktree ? ' ' + shArg(res.worktree) : ''
1064
+ return ' This is a worktree session and Leftoff couldn’t lock it (' + (res.why || 'no answer') + '), so it didn’t close the session: on /exit, Claude Code removes a worktree that has no changes. To keep it, run `git worktree lock' + (where || ' <worktree>') + '` first, or choose Keep if Claude Code asks.'
1065
+ }
1066
+
915
1067
  // The question Claude ended its turn on, if it ended on one: the last paragraph
916
1068
  // of the answer when its last sentence ends with "?" (markdown and closing
917
1069
  // quotes ignored). Code blocks don't count. -> string or null
@@ -19,7 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
- boardLinkLines, isNonInteractive,
22
+ boardLinkLines, isNonInteractive, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
23
23
  } from './core.js'
24
24
 
25
25
  const PANE_ID = 'checkpoints'
@@ -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.8.6' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.8.8' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
@@ -278,17 +278,45 @@ async function boardLinks($) {
278
278
  // only runs once Claude is done with the turn). If that's refused, it says to
279
279
  // type /exit. Nothing is ever killed.
280
280
  const PARK_FALLBACK = 'Parked. Type /exit when you\'re ready, and it\'ll stay on your board.'
281
+ const PARK_LOCK_LOST = 'Parked, but not closed: the worktree lock Leftoff took is gone, and on /exit Claude Code removes a worktree that has no changes. Lock it again (git worktree lock), or choose Keep if Claude Code asks.'
282
+ // 0.8.8: argv -> { exitCode, stdout, stderr } for the worktree helpers in core.js
283
+ async function gitFor($, argv) { return $.process.run(argv, { timeoutMs: 5000 }) }
281
284
  async function parkCommand($, args) {
282
285
  if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
283
286
  const a = parseParkArgs(args)
287
+ // 0.8.8: in a git worktree, lock it first: Claude Code's exit removes a worktree
288
+ // with no changes, and parking means coming back. No lock, no automatic /exit.
289
+ let cwd = null
290
+ try { cwd = await $.session.cwd() } catch {}
291
+ cwd = cwd || (st.record && st.record.cwd) || null
292
+ const run = (argv) => gitFor($, argv)
293
+ const wt = cwd ? await lockForPark(run, cwd, st.sessionId).catch((e) => ({ worktree: null, locked: false, unsure: true, why: String(e && e.message) })) : { worktree: null, locked: false, unsure: true, why: 'no folder on record' }
284
294
  const cp = await makeCheckpoint($, 'park', a.note, null, { next: a.note })
285
- if (!cp) return { text: 'Leftoff: a checkpoint is already being made. Try /cp-park again in a moment.' }
286
- await sendOrSpool($, 'session.parked', { note: a.note, checkpoint: cp.id || null, by: 'command' })
287
- // After this command returns, not inside it: the built-in /exit.
295
+ if (!cp) {
296
+ if (wt.locked) await unlockParkLocks(run, wt.root || wt.worktree, st.sessionId).catch(() => null) // not parked after all
297
+ return { text: 'Leftoff: a checkpoint is already being made. Try /cp-park again in a moment.' }
298
+ }
299
+ const worktreeLock = wt.locked ? { path: wt.worktree, root: wt.root || null, reason: wt.reason } : null
300
+ await sendOrSpool($, 'session.parked', { note: a.note, checkpoint: cp.id || null, by: 'command', ...(worktreeLock ? { worktreeLock } : {}) })
301
+ const head = 'Parked' + (a.note ? ': ' + clip(a.note, 120) : '') + '.'
302
+ if (wt.worktree || wt.unsure) {
303
+ if (!wt.locked) return { text: head + parkWorktreeText(wt) }
304
+ }
305
+ // After this command returns, not inside it: the built-in /exit (in a worktree, only while our lock still holds).
288
306
  $.clock.after(400, async () => {
307
+ if (worktreeLock && !(await parkLockHeld(run, worktreeLock.path, worktreeLock.reason))) { try { $.ui.toast(PARK_LOCK_LOST, { timeoutMs: 12000 }) } catch {} return }
289
308
  try { await $.command.run({ command: 'exit' }) } catch { try { $.ui.toast(PARK_FALLBACK, { timeoutMs: 8000 }) } catch {} }
290
309
  })
291
- return { text: 'Parked' + (a.note ? ': ' + clip(a.note, 120) : '') + '. Closing this session; it stays on your board under Parked.' }
310
+ return { text: head + ' Closing this session; it stays on your board under Parked.' + parkWorktreeText(wt) }
311
+ }
312
+
313
+ // 0.8.8: this session is back (claude --resume, or --worktree <name> --resume):
314
+ // unlock any worktree Leftoff locked when it was parked. Only its own lock.
315
+ async function unlockOwnParkLocks($, cwd) {
316
+ if (st.internal || !st.sessionId || !cwd) return
317
+ try {
318
+ await unlockParkLocks((argv) => gitFor($, argv), cwd, st.sessionId)
319
+ } catch {}
292
320
  }
293
321
 
294
322
  // ------------------------------------------------------------ lists (v0.5.1)
@@ -901,6 +929,7 @@ export function register(on, options) {
901
929
  st.backend = await readBackend($, model)
902
930
  st.hello = { cwd, repo, model, backend: st.backend, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
903
931
  await sendOrSpool($, 'session.opened', st.hello)
932
+ if (repo) await unlockOwnParkLocks($, cwd) // 0.8.8
904
933
  if (st.heartbeat) st.heartbeat.cancel()
905
934
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
906
935
  st.ready = true
@@ -923,6 +952,7 @@ export function register(on, options) {
923
952
  st.lastCheckpoint = null
924
953
  st.titleSoon = false
925
954
  st.daemonTitle = null
955
+ if (e.source === 'resume' && e.cwd) await unlockOwnParkLocks($, e.cwd) // 0.8.8: /resume of a parked worktree session
926
956
  }
927
957
  if (!st.sessionId) st.sessionId = e.session_id
928
958
  await sendOrSpool($, 'session.started', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.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
@@ -1630,3 +1630,26 @@ html.dv-open, html.dv-open body { overflow: hidden; }
1630
1630
  .emoji-sheet-wrap { align-items: flex-end; }
1631
1631
  .emoji-sheet { width: 100vw; border-radius: 16px 16px 0 0; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
1632
1632
  }
1633
+
1634
+ /* 0.8.7: a card's actions are one row: main button, copy icon, ⋯ (the rest in a menu) */
1635
+ .card > .actions-row { flex-wrap: nowrap; align-items: center; min-width: 0; }
1636
+ .card > .actions-row > .btn:not(.icon-btn), .card > .actions-row > .go-off { min-width: 0; flex: 0 1 auto; }
1637
+ .card > .actions-row > .btn:not(.icon-btn) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; line-height: 1.45; }
1638
+ .card > .actions-row > .btn:not(.icon-btn) .ic { vertical-align: -2px; margin-right: 5px; }
1639
+ .card > .actions-row > .go-off { display: inline-flex; align-items: center; gap: 6px; overflow: hidden; }
1640
+ .card > .actions-row > .go-off .why { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1641
+ .card > .actions-row > .ro { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1642
+ .btn.icon-btn { flex: none; width: 30px; height: 28px; padding: 0; justify-content: center; }
1643
+ .btn.icon-btn .ic { width: 15px; height: 15px; margin: 0; }
1644
+ .card > .actions-row > .btn.more { margin-left: auto; }
1645
+ .btn.copy-resume.copied { color: var(--green, #2fbf71); border-color: currentColor; }
1646
+ .lo-actmenu { min-width: 190px; max-width: min(320px, calc(100vw - 16px)); overflow-y: auto; }
1647
+ .lo-act { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; color: var(--text2); font: inherit; font-size: 14px; text-align: left; cursor: pointer; white-space: nowrap; }
1648
+ .lo-act .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
1649
+ .lo-act:hover, .lo-act:focus { outline: none; background: var(--bg2); color: var(--text); }
1650
+ [data-theme="dark"] .lo-act:hover, [data-theme="dark"] .lo-act:focus, :root:not([data-theme="light"]) .lo-act:hover, :root:not([data-theme="light"]) .lo-act:focus { background: var(--line); }
1651
+ .lo-act:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
1652
+ .lo-act:disabled { opacity: .55; cursor: progress; }
1653
+ .lo-menu.sheet.lo-actmenu { max-width: none; }
1654
+ .lo-menu.sheet .lo-act { padding: 14px 10px; font-size: 16px; }
1655
+ @media (pointer: coarse) { .btn.icon-btn { width: 36px; height: 34px; } }
package/ui/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
- import { selectHtml, comboHtml, closeMenu } from './picker.js'
5
+ import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
7
  import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches } from './repos.js'
8
8
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
@@ -98,7 +98,13 @@ function toastUndo(msg, onUndo, ms = 5000) {
98
98
  toast.t = setTimeout(() => t.classList.remove('show', 'has-action'), ms)
99
99
  }
100
100
  async function copy(text) {
101
- try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
101
+ try { await navigator.clipboard.writeText(text); toast('Copied: ' + text); return true } catch { prompt('Copy this:', text); return false }
102
+ }
103
+ // 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
104
+ function copiedMark(btn) {
105
+ clearTimeout(btn._copied)
106
+ btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
107
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
102
108
  }
103
109
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
104
110
 
@@ -402,8 +408,44 @@ function agentCard(s) {
402
408
  agentListChips(s) + (n ? cardBadge(n) : '') + agentCpBlock(s.latestCheckpoint) +
403
409
  '<a class="cplink" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session') + ' →</a>' +
404
410
  '<div class="actions-row">' + (n ? agentReplyButton(a, 'btn') : '') +
405
- '<span class="ro">read-only · from ' + esc(a.display || a.name) + '’s feed</span></div></article>'
411
+ roNote('read-only · from ' + (a.display || a.name) + '’s feed') + '</div></article>'
412
+ }
413
+
414
+ // 0.8.7: a card's actions are one row: its main button (Go, or Resume once it's
415
+ // closed), a small copy icon for the resume command, and "⋯" for the rest. The
416
+ // ⋯ menu's items ride in the card as an inert <template> (real buttons with the
417
+ // same data-action as before), so the one click handler below runs them unchanged.
418
+ function copyResumeIcon(s) {
419
+ return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
420
+ }
421
+ // items: [{ action, label, icon, title, attrs }]
422
+ function moreMenu(s, items) {
423
+ items = items.filter(Boolean)
424
+ if (!items.length) return ''
425
+ return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
426
+ '<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
427
+ Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
428
+ (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>').join('') + '</template>'
429
+ }
430
+ function openCardMenu(btn) {
431
+ const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
432
+ if (!tpl) return
433
+ const card = btn.closest('.card')
434
+ const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
435
+ const items = [...tpl.content.querySelectorAll('button')].map((b) => {
436
+ const svg = b.querySelector('svg')
437
+ const attrs = {}
438
+ for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
439
+ return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
440
+ })
441
+ openActionMenu(btn, items, { title })
406
442
  }
443
+ const sessionMenuItems = (s) => s.isLocal ? [
444
+ { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
445
+ { action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
446
+ { action: 'archive', label: 'Archive', icon: 'archive' },
447
+ ] : []
448
+ const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
407
449
 
408
450
  function card(s) {
409
451
  if (s.agent) return agentCard(s)
@@ -419,11 +461,9 @@ function card(s) {
419
461
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
420
462
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
421
463
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
422
- '<div class="actions-row">' + goButton(s) +
423
- '<button type="button" class="btn" data-action="resume">Copy resume</button>' +
424
- (s.isLocal ? '<button type="button" class="btn" data-action="checkpoint">Checkpoint now</button>' +
425
- '<button type="button" class="btn" data-action="pin">' + (s.pinned ? 'Unpin' : 'Pin') + '</button>' +
426
- '<button type="button" class="btn" data-action="archive">Archive</button>' : '<span class="ro">' + (s.peer && s.peer.ok ? 'lives on ' + esc(s.machine) + ' · answering and Go work through it' : 'read-only here · lives on ' + esc(s.machine)) + '</span>') +
464
+ '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
465
+ copyResumeIcon(s) +
466
+ (s.isLocal ? moreMenu(s, sessionMenuItems(s)) : roNote(s.peer && s.peer.ok ? 'lives on ' + s.machine + ' · answering and Go work through it' : 'read-only here · lives on ' + s.machine)) +
427
467
  '</div></article>'
428
468
  }
429
469
 
@@ -669,9 +709,8 @@ function closedCard(s, viewer) {
669
709
  '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
670
710
  '<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
671
711
  cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
672
- '<div class="actions-row">' + resumeButton(s, viewer) +
673
- '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
674
- '<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
712
+ '<div class="actions-row">' + resumeButton(s, viewer) + copyResumeIcon(s) +
713
+ moreMenu(s, [{ action: 'open-href', label: s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details', icon: 'history', attrs: { 'data-href': href } }]) +
675
714
  '</div></article>'
676
715
  }
677
716
  function closedRepaint() {
@@ -743,9 +782,8 @@ function parkedCard(s, viewer) {
743
782
  (s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
744
783
  '<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
745
784
  (note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
746
- '<div class="actions-row">' + main +
747
- (open ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>') +
748
- '<button type="button" class="linkish park-done" data-action="unpark" data-id="' + esc(s.id) + '" title="No longer parked' + (open ? '' : '; it goes to Closed') + '">Done with it</button>' +
785
+ '<div class="actions-row">' + main + (open ? '' : copyResumeIcon(s)) +
786
+ moreMenu(s, [{ action: 'unpark', label: 'Done with it', icon: 'circle-check', title: 'No longer parked' + (open ? '' : '; it goes to Closed') }]) +
749
787
  '</div></article>'
750
788
  }
751
789
  // The session page's Park row (0.8.2): any open session, Codex too, with an
@@ -2983,6 +3021,12 @@ document.addEventListener('input', (ev) => {
2983
3021
  })
2984
3022
  document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
2985
3023
 
3024
+ window.addEventListener('hashchange', () => closeActionMenu())
3025
+ // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
3026
+ document.addEventListener('keydown', (ev) => {
3027
+ const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
3028
+ if (b) actionMenuKey(b, ev, () => openCardMenu(b))
3029
+ })
2986
3030
  document.addEventListener('click', async (ev) => {
2987
3031
  const btn = ev.target.closest('[data-action]')
2988
3032
  if (!btn) return
@@ -3289,11 +3333,13 @@ document.addEventListener('click', async (ev) => {
3289
3333
  try { await api('POST', '/v1/needs/restore', action === 'need-restore-all' ? { all: true } : { key: btn.dataset.need }); toast(action === 'need-restore-all' ? 'Restored' : 'Restored to Needs you') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
3290
3334
  return render()
3291
3335
  }
3336
+ if (action === 'card-more') { ev.preventDefault(); return openCardMenu(btn) }
3337
+ if (action === 'open-href') { location.hash = btn.dataset.href; return }
3292
3338
  if (action === 'go') return goClick(btn, id, s)
3293
3339
  if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
3294
3340
  if (action === 'unpark') {
3295
3341
  btn.disabled = true
3296
- try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast(r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
3342
+ try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast((r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') + (r.worktreeUnlocked ? ' · its worktree is unlocked (still there)' : '')) } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
3297
3343
  return render()
3298
3344
  }
3299
3345
  if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
@@ -3304,9 +3350,10 @@ document.addEventListener('click', async (ev) => {
3304
3350
  if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
3305
3351
  if (action === 'resume') {
3306
3352
  const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
3307
- await copy(cmd)
3353
+ if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3308
3354
  } else if (action === 'checkpoint') {
3309
3355
  btn.disabled = true; btn.textContent = 'Writing…'
3356
+ if (!btn.isConnected) toast('Writing a checkpoint…', 20000) // from the ⋯ menu, which has closed
3310
3357
  const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
3311
3358
  toast('Checkpoint saved · Next: ' + cp.next)
3312
3359
  render()
package/ui/icons.js CHANGED
@@ -31,7 +31,14 @@ export const ICONS = {
31
31
  "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
32
32
  "chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
33
33
  "mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
34
- "search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>"
34
+ "search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
35
+ "copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
36
+ "ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
37
+ "pin": "<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\"/>",
38
+ "pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
39
+ "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
40
+ "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
41
+ "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
35
42
  }
36
43
 
37
44
  export function icon(name, cls = 'ic') {
package/ui/picker.js CHANGED
@@ -276,13 +276,117 @@ function onKey(host, e) {
276
276
  }
277
277
  }
278
278
 
279
+
280
+ // 0.8.7: a card's "⋯" menu. Not a select: a list of actions (role menu, items
281
+ // role menuitem). The items are real <button data-action …> elements, so the
282
+ // board's one click handler runs them exactly as it ran the old row of buttons;
283
+ // choosing one closes the menu. Same look as the selects' menu: under (or
284
+ // above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
285
+ // Enter/Space, Escape (focus back on the ⋯), Tab, and a tap outside closes.
286
+ // items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls }]
287
+ let act = null // { anchor, menu, backdrop, items }
288
+ export function actionMenuOpen() { return !!act }
289
+ export function closeActionMenu({ focus = false } = {}) {
290
+ if (!act) return
291
+ const { anchor, menu, backdrop } = act
292
+ act = null
293
+ menu.remove(); if (backdrop) backdrop.remove()
294
+ anchor.setAttribute('aria-expanded', 'false')
295
+ if (focus && anchor.isConnected) anchor.focus()
296
+ }
297
+ function placeAction() {
298
+ if (!act) return
299
+ const { menu, anchor } = act
300
+ const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
301
+ if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
302
+ const r = anchor.getBoundingClientRect()
303
+ const pad = 8
304
+ const w = menu.offsetWidth, h = menu.offsetHeight
305
+ const up = vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
306
+ // right edges line up (the ⋯ sits at the end of the row), never past the viewport
307
+ menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
308
+ menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
309
+ menu.classList.toggle('up', up)
310
+ }
311
+ function actFocus(k) {
312
+ if (!act) return
313
+ const n = act.items.length
314
+ if (!n) return
315
+ act.items[((k % n) + n) % n].focus()
316
+ }
317
+ export function openActionMenu(anchor, items, { title = '' } = {}) {
318
+ if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
319
+ closeActionMenu(); closeMenu()
320
+ const sheet = narrow()
321
+ const id = 'lo-act' + (++uid)
322
+ const menu = document.createElement('div')
323
+ menu.className = 'lo-menu lo-actmenu' + (sheet ? ' sheet' : '')
324
+ menu.id = id
325
+ menu.setAttribute('role', 'menu')
326
+ if (title) menu.setAttribute('aria-label', title)
327
+ menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
328
+ items.map((it) => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') + '<span>' + esc(it.label) + '</span></button>').join('')
329
+ let backdrop = null
330
+ if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
331
+ document.body.appendChild(menu)
332
+ act = { anchor, menu, backdrop, items: [...menu.querySelectorAll('[role="menuitem"]')] }
333
+ anchor.setAttribute('aria-haspopup', 'menu')
334
+ anchor.setAttribute('aria-controls', id)
335
+ anchor.setAttribute('aria-expanded', 'true')
336
+ menu.addEventListener('click', (e) => {
337
+ if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
338
+ // the item's own click goes on to the board's handler (the event's path is
339
+ // already fixed); the menu just gets out of the way
340
+ if (e.target.closest('[role="menuitem"]')) closeActionMenu()
341
+ })
342
+ menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
343
+ menu.addEventListener('keydown', (e) => {
344
+ const k = act ? act.items.indexOf(document.activeElement) : -1
345
+ switch (e.key) {
346
+ case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
347
+ case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
348
+ case 'Home': e.preventDefault(); actFocus(0); return
349
+ case 'End': e.preventDefault(); actFocus(-1); return
350
+ case 'Escape': e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }); return
351
+ case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
352
+ default:
353
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
354
+ const ch = e.key.toLowerCase()
355
+ const j = act.items.findIndex((x, i) => i > k && x.textContent.trim().toLowerCase().startsWith(ch))
356
+ const j2 = j >= 0 ? j : act.items.findIndex((x) => x.textContent.trim().toLowerCase().startsWith(ch))
357
+ if (j2 >= 0) { e.preventDefault(); actFocus(j2) }
358
+ }
359
+ }
360
+ })
361
+ placeAction()
362
+ actFocus(0)
363
+ return menu
364
+ }
365
+ // the ⋯ button itself: ArrowDown/Up opens the menu too (Enter and Space click it)
366
+ export function actionMenuKey(anchor, e, open) {
367
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); if (act && e.key === 'ArrowUp') actFocus(-1) }
368
+ }
369
+
279
370
  if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
280
371
  customElements.define('lo-select', LoSelect)
281
372
  customElements.define('lo-combo', LoCombo)
282
373
  // a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
283
374
  document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
284
- window.addEventListener('resize', () => { if (open) closeMenu() })
285
- document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place() }, true)
375
+ window.addEventListener('resize', () => { if (open) closeMenu(); if (act) closeActionMenu() })
376
+ document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place(); if (act && !act.menu.contains(e.target)) placeAction() }, true)
377
+ // a tap outside a card's ⋯ menu closes it (and goes nowhere: a tap on the backdrop, or anywhere on a desktop page, only closes)
378
+ document.addEventListener('pointerdown', (e) => {
379
+ if (!act || act.menu.contains(e.target) || act.anchor.contains(e.target)) return
380
+ closeActionMenu()
381
+ // the click that ends this same tap is eaten; a later tap elsewhere is not
382
+ const x = e.clientX, y = e.clientY, at = Date.now()
383
+ const swallow = (ev) => {
384
+ document.removeEventListener('click', swallow, true)
385
+ if (Date.now() - at < 600 && Math.abs(ev.clientX - x) < 12 && Math.abs(ev.clientY - y) < 12) { ev.preventDefault(); ev.stopPropagation() }
386
+ }
387
+ document.addEventListener('click', swallow, true)
388
+ setTimeout(() => document.removeEventListener('click', swallow, true), 600)
389
+ }, true)
286
390
  // <label for="f-tz"> focuses (and on a click, opens) the control, as it would a select
287
391
  document.addEventListener('click', (e) => {
288
392
  const l = e.target.closest && e.target.closest('label[for]')