@leftoff/cli 0.9.17-beta.5 → 0.9.17-beta.7
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 +7 -0
- package/bin/leftoff.js +445 -440
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +6 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +182 -15
- package/ui/app.js +334 -29
- package/ui/icons.js +3 -0
- package/ui/index.html +16 -12
- package/ui/live-view.js +3 -0
- package/ui/navrail.js +99 -0
- package/ui/needs.js +66 -20
- package/ui/prs.js +53 -8
- package/ui/scroll-keep.js +247 -0
- package/ui/walk-pop.js +3 -3
package/ui/index.html
CHANGED
|
@@ -20,29 +20,33 @@
|
|
|
20
20
|
<link rel="stylesheet" href="app.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
|
-
<header class="top">
|
|
24
|
-
<a class="brand" href="#/" id="brand"
|
|
23
|
+
<header class="top" id="navRail">
|
|
24
|
+
<a class="brand" href="#/" id="brand" data-tip="Leftoff" aria-label="Leftoff">
|
|
25
25
|
<img src="favicon.svg" class="logo" alt="" width="22" height="22">
|
|
26
26
|
<span>Leftoff</span>
|
|
27
27
|
</a>
|
|
28
28
|
<nav class="tabs" aria-label="Sections">
|
|
29
|
-
<a href="#/" data-route="board"><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><span class="lbl">Board</span></a>
|
|
30
|
-
<a href="#/waiting" data-route="waiting" id="needsTab" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><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><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
|
-
<a href="#/lists" data-route="lists" id="listsTab"><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="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
|
-
<a href="#/prs" data-route="prs" id="prsTab"><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="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
33
|
-
<a href="#/timeline" data-route="timeline" class="tab-extra"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
34
|
-
<a href="#/digest" data-route="digest" class="tab-extra"><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 class="lbl">Digest</span></a>
|
|
29
|
+
<a href="#/" data-route="board" data-tip="Board"><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><span class="lbl">Board</span></a>
|
|
30
|
+
<a href="#/waiting" data-route="waiting" id="needsTab" data-tip="Needs you" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><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><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
|
+
<a href="#/lists" data-route="lists" id="listsTab" data-tip="Lists"><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="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
|
+
<a href="#/prs" data-route="prs" id="prsTab" data-tip="PRs"><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="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
33
|
+
<a href="#/timeline" data-route="timeline" class="tab-extra" data-tip="Timeline"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
34
|
+
<a href="#/digest" data-route="digest" class="tab-extra" data-tip="Digest"><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 class="lbl">Digest</span></a>
|
|
35
35
|
<button type="button" class="tab-more" id="moreBtn" aria-haspopup="menu" aria-expanded="false" aria-controls="moreMenu"><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><span class="lbl">More</span><span class="moredot" hidden></span></button>
|
|
36
36
|
</nav>
|
|
37
37
|
<div class="topright">
|
|
38
|
-
<button type="button" class="searchbtn" id="searchBtn"
|
|
38
|
+
<button type="button" class="searchbtn" id="searchBtn" data-tip="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><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="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
39
39
|
<a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><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="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
|
|
40
40
|
<button type="button" id="termBtn" class="iconbtn termbtn" data-term-toggle data-tip="Terminal (⌃`)" aria-label="Terminal" aria-expanded="false" aria-controls="termDock" aria-keyshortcuts="Control+`"><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 19h8"/> <path d="m4 17 6-6-6-6"/></svg><span class="lbl">Terminal</span></button>
|
|
41
|
-
<button type="button" class="health" id="health"
|
|
42
|
-
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn"
|
|
43
|
-
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn"
|
|
41
|
+
<button type="button" class="health" id="health" data-tip="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
42
|
+
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" data-tip="What’s new" aria-label="What’s new"><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 class="lbl">What’s new</span><span class="newdot" hidden></span></a>
|
|
43
|
+
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" data-tip="Settings" aria-label="Settings"><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 class="lbl">Settings</span><span class="topdot" hidden></span></a>
|
|
44
|
+
<button type="button" class="iconbtn navtoggle" id="navToggle" aria-controls="navRail" aria-expanded="true" aria-label="Collapse the sidebar" aria-keyshortcuts="Meta+B Control+B [" data-tip="Collapse sidebar (⌘B)"><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="18" height="18" x="3" y="3" rx="2"/> <path d="M9 3v18"/> <path d="m16 15-3-3 3-3"/></svg><span class="lbl">Collapse</span></button>
|
|
44
45
|
</div>
|
|
45
46
|
</header>
|
|
47
|
+
<div class="navscrim" id="navScrim" hidden></div>
|
|
48
|
+
<div class="navdrawer" id="navDrawer" role="dialog" aria-modal="true" aria-label="Menu" hidden></div>
|
|
49
|
+
<div class="railtip" id="railTip" role="tooltip" hidden></div>
|
|
46
50
|
<div class="moremenu" id="moreMenu" role="menu" aria-label="More" hidden>
|
|
47
51
|
<a href="#/timeline" data-route="timeline" 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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span>Timeline</span></a>
|
|
48
52
|
<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>
|
package/ui/live-view.js
CHANGED
|
@@ -161,6 +161,7 @@ export function senderLabel(m) {
|
|
|
161
161
|
if (m.from === 'claude') return m.agentName || 'Claude' // 0.9.17: an ended Codex session's answers say Codex
|
|
162
162
|
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
163
163
|
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
164
|
+
if (m.origin === 'needs') return 'You · from Needs you' // 2026-10-29: a reply sent from a Needs-you card (its own words, not the transcript's form)
|
|
164
165
|
if (m.history && !m.by) return 'You · board'
|
|
165
166
|
const by = m.by || {}
|
|
166
167
|
let device = by.device || null
|
|
@@ -242,6 +243,8 @@ export function fullButtonHtml(id, { esc, icon }) {
|
|
|
242
243
|
// name goes in as text (the caller fills .lf-title with textContent).
|
|
243
244
|
export function fullBarHtml(id, { esc, icon, pills = '', stop = '', retitle = '', glyph = '' }) {
|
|
244
245
|
return '<div class="live-fbar">' +
|
|
246
|
+
// 0.9.18 phones: the nav as a drawer (ui/navrail.js); from 700px the rail is beside full screen and this hides
|
|
247
|
+
'<button type="button" class="btn lf-menu" data-nav-drawer aria-label="Menu" aria-expanded="false" aria-controls="navDrawer" data-tip="Menu">' + icon('menu') + '</button>' +
|
|
245
248
|
'<button type="button" class="btn live-fs-x" data-action="live-full-exit" data-id="' + esc(id) + '" aria-label="Back to the board" data-tip="Board">' + icon('chevron-left') + '</button>' + // 0.9.16 one layout: back is the board
|
|
246
249
|
glyph + '<h2 class="lf-title"></h2>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
|
|
247
250
|
// 0.9.15 the embedded terminal (ui/term.js): docked under full screen, which gets shorter
|
package/ui/navrail.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 0.9.18 The left nav on every screen (feedback, Oct 28: "it seems weird that I
|
|
2
|
+
// need to click on Board first to see the left hand nav").
|
|
3
|
+
//
|
|
4
|
+
// From 700px the rail stays beside every page, full screen and the walkthrough
|
|
5
|
+
// included. Two widths: expanded (84px, icon over label, the rail as it always
|
|
6
|
+
// was) and collapsed (56px, icons with a tip). The toggle at the rail's foot,
|
|
7
|
+
// ⌘B, or [ when you're not typing, switches them, remembered on this device. When the
|
|
8
|
+
// room is short (under 1100px, or a full-screen chat that would get narrower
|
|
9
|
+
// than NAV_CHAT_MIN beside the session panel) the rail collapses for that view
|
|
10
|
+
// only: your saved choice is untouched and comes back when there's room.
|
|
11
|
+
//
|
|
12
|
+
// Under 700px there's no rail: a menu button (and, in a Home Screen app or a
|
|
13
|
+
// non-Safari browser, a swipe in from the left edge) opens the nav as a drawer
|
|
14
|
+
// over the page. Safari's own back-swipe owns that edge in the browser, so there
|
|
15
|
+
// the button is the way in.
|
|
16
|
+
//
|
|
17
|
+
// Pure functions and a tiny store (the unit tests use a fake storage); app.js wires them.
|
|
18
|
+
|
|
19
|
+
export const NAV_KEY = 'lo-nav-v1' // '1' collapsed, '0' expanded (an explicit choice); none: expanded
|
|
20
|
+
export const RAIL_MIN = 700 // from here the rail; under it the phone's bar and the drawer
|
|
21
|
+
export const RAIL_ROOMY = 1100 // under this the rail is collapsed whatever you chose
|
|
22
|
+
export const RAIL_W_OPEN = 84, RAIL_W_SLIM = 56
|
|
23
|
+
export const NAV_CHAT_MIN = 560 // a full-screen chat keeps at least this beside an expanded rail
|
|
24
|
+
export const EDGE_PX = 20 // a drawer swipe starts this close to the left edge
|
|
25
|
+
export const SWIPE_MIN = 48 // and travels at least this far, mostly sideways
|
|
26
|
+
|
|
27
|
+
export function createNavStore(storage) {
|
|
28
|
+
const stored = () => { try { const v = storage ? storage.getItem(NAV_KEY) : null; return v === '1' || v === '0' ? v : null } catch { return null } }
|
|
29
|
+
return {
|
|
30
|
+
collapsed: () => stored() === '1',
|
|
31
|
+
explicit: () => stored() != null,
|
|
32
|
+
set(collapsed) { try { storage && storage.setItem(NAV_KEY, collapsed ? '1' : '0') } catch {} },
|
|
33
|
+
toggle() { const c = stored() !== '1'; this.set(c); return c },
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What the rail shows now. width: the window's CSS width; saved: your choice
|
|
38
|
+
// (true = collapsed); side: the full-screen session panel's width when it's open
|
|
39
|
+
// beside the chat (0 when there's none, or it's a rail / collapsed).
|
|
40
|
+
// → { rail, collapsed, auto } — auto: collapsed by the room, not by you.
|
|
41
|
+
export function navState({ width = 0, saved = false, side = 0 } = {}) {
|
|
42
|
+
const w = Number(width) || 0
|
|
43
|
+
if (w < RAIL_MIN) return { rail: false, collapsed: false, auto: false }
|
|
44
|
+
const tight = w < RAIL_ROOMY || (side > 0 && w - RAIL_W_OPEN - side < NAV_CHAT_MIN)
|
|
45
|
+
if (saved) return { rail: true, collapsed: true, auto: false }
|
|
46
|
+
return { rail: true, collapsed: tight, auto: tight }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The toggle's words for the state shown.
|
|
50
|
+
export function toggleWords({ collapsed = false, auto = false } = {}, key = '⌘B') {
|
|
51
|
+
if (auto) return { label: 'Expand the sidebar', tip: 'Not enough room to expand here', icon: 'panel-left-open' }
|
|
52
|
+
return collapsed
|
|
53
|
+
? { label: 'Expand the sidebar', tip: 'Expand sidebar (' + key + ')', icon: 'panel-left-open' }
|
|
54
|
+
: { label: 'Collapse the sidebar', tip: 'Collapse sidebar (' + key + ')', icon: 'panel-left-close' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ⌘B (Ctrl+B off a Mac) toggles the rail from anywhere, the composer included,
|
|
58
|
+
// but not inside the terminal (Ctrl+B is tmux's); [ does too when you're not typing.
|
|
59
|
+
// (⌘\ is the session panel's, ⌘⇧[ ] the tabs'.)
|
|
60
|
+
export function isNavKey(ev, { typing = false, term = false, mac = true } = {}) {
|
|
61
|
+
if (!ev || ev.altKey) return false
|
|
62
|
+
const mod = mac ? ev.metaKey && !ev.ctrlKey : ev.ctrlKey && !ev.metaKey
|
|
63
|
+
if (mod && !ev.shiftKey && (ev.key === 'b' || ev.key === 'B')) return !term
|
|
64
|
+
return ev.key === '[' && !ev.metaKey && !ev.ctrlKey && !typing
|
|
65
|
+
}
|
|
66
|
+
export const navKeyLabel = (mac = true) => (mac ? '⌘B' : 'Ctrl+B')
|
|
67
|
+
|
|
68
|
+
// Mobile Safari in the browser (not a Home Screen app): its back-swipe owns the left edge.
|
|
69
|
+
export function safariOwnsEdge(ua = '', standalone = false) {
|
|
70
|
+
const s = String(ua)
|
|
71
|
+
const ios = /iP(hone|ad|od)/.test(s) || (/Macintosh/.test(s) && /Mobile/.test(s))
|
|
72
|
+
return ios && !standalone && /Safari/.test(s) && !/CriOS|FxiOS|EdgiOS/.test(s)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A touch that starts this close to the left edge may open the drawer.
|
|
76
|
+
export const edgeStart = (x, edge = EDGE_PX) => Number(x) >= 0 && Number(x) <= edge
|
|
77
|
+
// What a finished swipe means: 'open' (rightwards from the edge), 'close'
|
|
78
|
+
// (leftwards on the open drawer) or null (too short, or mostly up and down).
|
|
79
|
+
export function swipeIntent(dx, dy, open) {
|
|
80
|
+
const x = Number(dx) || 0, y = Number(dy) || 0
|
|
81
|
+
if (Math.abs(x) < SWIPE_MIN || Math.abs(x) < Math.abs(y) * 1.5) return null
|
|
82
|
+
if (!open && x > 0) return 'open'
|
|
83
|
+
if (open && x < 0) return 'close'
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
88
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
89
|
+
|
|
90
|
+
// The drawer: the nav's destinations as one list. items: [{ href, route, label, svg, on, count }]
|
|
91
|
+
// (svg is the nav's own inline icon markup).
|
|
92
|
+
export function drawerHtml(items = [], { title = 'Leftoff' } = {}) {
|
|
93
|
+
const row = (it) => '<a class="nd-item' + (it.on ? ' on' : '') + '" href="' + esc(it.href) + '"' + (it.route ? ' data-route="' + esc(it.route) + '"' : '') + (it.on ? ' aria-current="page"' : '') + '>' +
|
|
94
|
+
(it.svg || '') + '<span class="nd-lbl">' + esc(it.label) + '</span>' + (it.on ? '<span class="nd-cur">Current</span>' : '') +
|
|
95
|
+
(it.count ? '<span class="nd-n" aria-label="' + esc(it.count + ' waiting') + '">' + esc(String(it.count)) + '</span>' : '') + '</a>'
|
|
96
|
+
return '<div class="nd-head"><img src="favicon.svg" class="logo" alt="" width="22" height="22"><span class="nd-title">' + esc(title) + '</span>' +
|
|
97
|
+
'<button type="button" class="nd-x" data-nd-close aria-label="Close the menu" data-tip="Close (Esc)"><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></button></div>' +
|
|
98
|
+
'<nav class="nd-list" aria-label="Sections">' + items.map(row).join('') + '</nav>'
|
|
99
|
+
}
|
package/ui/needs.js
CHANGED
|
@@ -434,9 +434,6 @@ export function prCardHtml(pr) {
|
|
|
434
434
|
: '<div class="need-pr">' + inner + '</div>'
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
export const GENERIC_REPLIES = ['Yes, go ahead', 'No', 'Show me the diff first']
|
|
438
|
-
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
439
|
-
|
|
440
437
|
// "Should I also add Google sign-in, or keep it email-only?" -> the two choices.
|
|
441
438
|
export function orChoices(question) {
|
|
442
439
|
const q = String(question || '').replace(/\s+/g, ' ').trim()
|
|
@@ -462,29 +459,78 @@ export function listChoices(question) {
|
|
|
462
459
|
return items.map(cap)
|
|
463
460
|
}
|
|
464
461
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
462
|
+
// 2026-10-29 (beta feedback: "a lot of the time the suggestions on Needs you are
|
|
463
|
+
// bad"): a suggestion is either one of the question's own choices, word for word,
|
|
464
|
+
// or a plain answer to a yes/no question. Anything else gets no suggestions: the
|
|
465
|
+
// old fallback ("Yes, go ahead" / "No" / "Show me the diff first") showed up under
|
|
466
|
+
// questions that need a specific answer ("What should the timeout be?"), and
|
|
467
|
+
// "Show me the diff first" was added to every either/or question.
|
|
468
|
+
export const GENERIC_REPLIES = ['Yes, go ahead', 'No'] // a yes/no question's answers (the name is kept for older imports)
|
|
469
|
+
export const YES_NO = GENERIC_REPLIES
|
|
470
|
+
export const REPLY_MAX_CHARS = 100 // a suggestion longer than this isn't a quick reply
|
|
471
|
+
export const REPLY_MAX = 6 // at most this many (a numbered menu can have six)
|
|
472
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
473
|
+
|
|
474
|
+
// The sentence that asks: the last one ending in "?" (markup off, one space).
|
|
475
|
+
export function askingSentence(question) {
|
|
476
|
+
const parts = String(question || '').replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+(?=\S)/)
|
|
477
|
+
return (parts.filter((p) => /\?\s*$/.test(p)).pop() || '').trim()
|
|
478
|
+
}
|
|
479
|
+
// What kind of answer a question wants: 'yesno' (Should I…? Want me to…? Does
|
|
480
|
+
// this plan look good? Ready to merge? (y/n)), 'open' (What / Which / How… with
|
|
481
|
+
// no choices to pick from), or 'unknown'. Either/or questions are neither: their
|
|
482
|
+
// choices are the answers (orChoices / listChoices).
|
|
483
|
+
const OPEN_RE = /^(?:so,?\s+|and\s+|also,?\s+|ok(?:ay)?,?\s+)?(?:what|which|how|where|when|who|whom|whose|why|any\s+(?:idea|preference|thoughts)|anything\s+else|is\s+there\s+anything)\b/i
|
|
484
|
+
const YESNO_START = /^(?:should|shall|do|does|did|is|are|was|were|can|could|may|might|would|will|want|ready|ok(?:ay)?|sound|sounds|look|looks|happy|good|proceed|continue|go\s+ahead|all\s+good|fine|alright|cool|safe|mind\s+if)\b/i
|
|
485
|
+
const YESNO_ANY = /\b(?:want me to|should i|shall i|ok(?:ay)? (?:if i|to)|good to go|go ahead|look(?:s)? good|sound(?:s)? good|look(?:s)? right|approve|proceed|ready (?:to|for)|(?:do|would) you like me to)\b/i
|
|
486
|
+
export function questionShape(question) {
|
|
487
|
+
const raw = String(question || '')
|
|
488
|
+
if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b|\[y\/n\]/i.test(raw)) return 'yesno'
|
|
489
|
+
const q = askingSentence(raw)
|
|
490
|
+
if (!q) return 'unknown'
|
|
491
|
+
// the clause that asks ("If the tests pass, want me to push?" -> "want me to push?")
|
|
492
|
+
const clause = q.split(/,\s+|;\s+|\s[—–-]\s/).pop().trim()
|
|
493
|
+
if (OPEN_RE.test(q) || OPEN_RE.test(clause)) return 'open'
|
|
494
|
+
if (/\bor\b/i.test(clause)) return 'unknown' // either/or: yes isn't an answer
|
|
495
|
+
if (YESNO_START.test(q) || YESNO_START.test(clause) || YESNO_ANY.test(clause)) return 'yesno'
|
|
496
|
+
return 'unknown'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Kept to quick-reply size, duplicates out, at most REPLY_MAX.
|
|
500
|
+
function tidyReplies(list) {
|
|
501
|
+
const out = []
|
|
502
|
+
for (const t of list || []) {
|
|
503
|
+
const v = String(t == null ? '' : t).replace(/\s+/g, ' ').trim()
|
|
504
|
+
if (!v || v.length > REPLY_MAX_CHARS || out.includes(v)) continue
|
|
505
|
+
out.push(v)
|
|
506
|
+
}
|
|
507
|
+
return out.slice(0, REPLY_MAX)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The suggestions for a waiting question. A question's own choices first, word
|
|
511
|
+
// for word (AskUserQuestion's options, "…: A, B, or C?", a numbered menu the
|
|
512
|
+
// question follows, "X, or Y?"); then Yes / No for a yes/no question; else none.
|
|
513
|
+
// A pick fills the answer box; you send it.
|
|
468
514
|
export function quickReplies(n) {
|
|
469
515
|
if (!n) return []
|
|
516
|
+
let question = ''
|
|
470
517
|
if (n.kind === 'question' && n.ask) {
|
|
471
518
|
const qs = n.ask.questions || []
|
|
472
519
|
if (qs.length !== 1) return []
|
|
473
520
|
const q = qs[0]
|
|
474
|
-
if (q.options && q.options.length) return q.multiSelect ? [] : q.options.map((o) => o.label).slice(0, 4)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
return GENERIC_REPLIES
|
|
521
|
+
if (q.options && q.options.length) return q.multiSelect ? [] : tidyReplies(q.options.map((o) => o.label)).slice(0, 4)
|
|
522
|
+
question = q.question
|
|
523
|
+
} else if (n.kind === 'reply') {
|
|
524
|
+
question = n.question
|
|
525
|
+
} else return []
|
|
526
|
+
const inl = listChoices(question) // the question's own list (a peer's older daemon may still send a list's items)
|
|
527
|
+
if (inl.length) return tidyReplies(inl)
|
|
528
|
+
const listed = n.kind === 'reply' ? ((n.context && n.context.options) || []) : []
|
|
529
|
+
if (listed.length >= 2) return tidyReplies(listed)
|
|
530
|
+
const alt = orChoices(question)
|
|
531
|
+
if (alt.length) return tidyReplies(alt)
|
|
532
|
+
if (questionShape(question) === 'yesno') return /\(\s*y\s*\/\s*n\s*\)|\[y\/n\]/i.test(String(question || '')) ? ['Yes', 'No'] : YES_NO.slice()
|
|
533
|
+
return []
|
|
488
534
|
}
|
|
489
535
|
|
|
490
536
|
// The draft a card's quick replies fill (the Reply box, or a question's "Other").
|
package/ui/prs.js
CHANGED
|
@@ -28,15 +28,41 @@ export function checksHtml(ck, { compact = false } = {}) {
|
|
|
28
28
|
return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
|
|
29
29
|
}
|
|
30
30
|
const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
|
|
31
|
+
// 2026-10-29 State by shape plus the word (never colour alone): open ⟟ a PR glyph, draft one with a
|
|
32
|
+
// dashed head, merged the merge glyph, closed the PR glyph with an ✕; not checked a dashed ring.
|
|
33
|
+
const svg = (d) => '<svg class="pr-st-ic" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
|
|
34
|
+
export const STATE_IC = {
|
|
35
|
+
open: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/>'),
|
|
36
|
+
draft: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><path d="M6 9v12"/>'),
|
|
37
|
+
merged: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>'),
|
|
38
|
+
closed: svg('<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/><path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>'),
|
|
39
|
+
unchecked: svg('<circle cx="12" cy="12" r="8" stroke-dasharray="3 3.3"/>'),
|
|
40
|
+
}
|
|
31
41
|
export function stateHtml(state) {
|
|
32
42
|
if (!state) return ''
|
|
33
|
-
return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
|
|
43
|
+
return '<span class="pr-state ' + esc(state) + '">' + (STATE_IC[state] || '') + esc(STATE[state] || state) + '</span>'
|
|
44
|
+
}
|
|
45
|
+
// 2026-10-29 A short ref that couldn't be checked: why, for its data-tip.
|
|
46
|
+
export function uncheckedTip(p) {
|
|
47
|
+
const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
|
|
48
|
+
const name = String(what).split('#')[0]
|
|
49
|
+
if (p.unchecked === 'ambiguous') return 'Named in this session, but “' + name + '” is more than one GitHub repo here' + (p.options && p.options.length ? ' (' + p.options.join(', ') + ')' : '') + ', so it wasn’t checked. Link the right one below.'
|
|
50
|
+
if (p.unchecked === 'unknown') return 'Named in this session, but Leftoff doesn’t know which GitHub repo “' + name + '” is yet (this session has no GitHub remote), so it wasn’t checked. It links once a session in that repo runs on one of your machines, or link it below.'
|
|
51
|
+
if (p.unchecked === 'no_gh') return 'Named in this session (' + (p.repo || name) + '). No machine with gh signed in has answered yet, so it wasn’t checked.'
|
|
52
|
+
if (p.unchecked === 'failed') return 'Named in this session (' + (p.repo || name) + '), but gh couldn’t read it just now, so it wasn’t checked. It tries again in a few minutes.'
|
|
53
|
+
return 'Named in this session (' + (p.repo || name) + '). Checking it with gh…'
|
|
54
|
+
}
|
|
55
|
+
export function uncheckedHtml(p) {
|
|
56
|
+
const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
|
|
57
|
+
const word = p.unchecked === 'checking' ? 'checking' : 'not checked'
|
|
58
|
+
const tip = uncheckedTip(p)
|
|
59
|
+
return '<span class="pr-unchecked" tabindex="0" role="note" data-tip="' + esc(tip) + '" aria-label="' + esc(what + ', ' + word + '. ' + tip) + '">' + STATE_IC.unchecked + '<span class="pr-un-ref">' + esc(what) + '</span><span class="pr-un-word"> · ' + esc(word) + '</span></span>'
|
|
34
60
|
}
|
|
35
61
|
const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
|
|
36
62
|
|
|
37
63
|
// A session card's PRs: one compact line each (#, title, state, checks), three at most.
|
|
38
64
|
export function cardPrsHtml(prs) {
|
|
39
|
-
const list = (prs || []).filter((p) => p && p.number)
|
|
65
|
+
const list = (prs || []).filter((p) => p && p.number && !p.unchecked)
|
|
40
66
|
if (!list.length) return ''
|
|
41
67
|
const shown = list.slice(0, 3)
|
|
42
68
|
const more = list.length - shown.length
|
|
@@ -80,8 +106,12 @@ export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
|
80
106
|
|
|
81
107
|
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
82
108
|
export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
// 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
|
|
110
|
+
const more = (s.prsMore || []).filter((p) => p && p.number)
|
|
111
|
+
const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
|
|
112
|
+
const unchecked = more.filter((p) => p.unchecked)
|
|
113
|
+
const n = list.length + unchecked.length
|
|
114
|
+
let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
|
|
85
115
|
if (list.length) {
|
|
86
116
|
h += '<div class="prlist">' + list.map((p) => {
|
|
87
117
|
// 0.9.8: tap the title (or More…) for the same details as the PRs page, in place; GitHub ↗ still goes out
|
|
@@ -93,13 +123,28 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
93
123
|
(isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
94
124
|
prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
|
|
95
125
|
}).join('') + '</div>'
|
|
96
|
-
}
|
|
97
|
-
if (
|
|
126
|
+
}
|
|
127
|
+
if (unchecked.length) h += '<div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div>'
|
|
128
|
+
if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
|
|
129
|
+
(mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
|
|
130
|
+
if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
98
131
|
return h
|
|
99
132
|
}
|
|
133
|
+
// 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
|
|
134
|
+
const PR_HOW = 'PRs link on their own from gh pr create, a PR link or number in your messages or the title, gh pr checkout, a push to a PR’s branch, or the branch itself.'
|
|
135
|
+
const prInputId = (id) => 'prref-' + String(id || '').replace(/[^A-Za-z0-9_-]/g, '_')
|
|
136
|
+
const DEFAULTS = ['main', 'master', 'trunk', 'develop']
|
|
137
|
+
export function noPrWhy(s) {
|
|
138
|
+
const r = (s && s.repo) || {}
|
|
139
|
+
if (!r.root && !r.name && !(s && s.repoName && s.repoName !== 'no-repo')) return 'not in a git repo'
|
|
140
|
+
if (r.branch && DEFAULTS.includes(r.branch)) return 'it ran on ' + r.branch + ' and named no PR'
|
|
141
|
+
if (r.branch) return 'no open PR for ' + r.branch
|
|
142
|
+
return ''
|
|
143
|
+
}
|
|
100
144
|
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
101
|
-
|
|
102
|
-
const
|
|
145
|
+
// 2026-10-28: + gh (worked on it with gh), pushed, title, commits, ghview.
|
|
146
|
+
export const WHY = { named: 'named in this session', suggested: 'named in this session', created: 'created here', gh: 'worked on here', pushed: 'pushed to its branch', you: 'you mentioned it', title: 'named in the title', commits: 'has this session’s commits', branch: 'same branch', ghview: 'looked up here', mentioned: 'same branch', manual: 'linked by you' }
|
|
147
|
+
const WHY_ORDER = ['manual', 'created', 'gh', 'pushed', 'you', 'suggested', 'title', 'commits', 'branch', 'ghview', 'named', 'mentioned']
|
|
103
148
|
export function whyText(r) { const v = r && (r.why || (Array.isArray(r.via) ? WHY_ORDER.find((k) => r.via.includes(k)) : r.via)); return v ? WHY[v] || '' : '' }
|
|
104
149
|
|
|
105
150
|
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|