@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/ui/app.js CHANGED
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
34
34
  import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
35
35
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
36
36
  import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
37
+ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
37
38
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
38
39
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
39
40
  import { mdInline, renderSection } from './changelog-md.js'
@@ -71,7 +72,214 @@ import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claud
71
72
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
72
73
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
73
74
  import { glyphState, glyphHtml, glyphWords, lastReplyOf, seenAtFor, ensureSeenBase, markSeen, GLYPH_LABEL, offlineWords, offlineTip, waitingOnYou } from './sglyph.js' // 0.9.14 status glyphs
75
+ import { createNavStore, navState, toggleWords, isNavKey, navKeyLabel, safariOwnsEdge, edgeStart, swipeIntent, drawerHtml, RAIL_MIN, RAIL_W_SLIM } from './navrail.js'
76
+
77
+ // ------------------------------------------------------------ 0.9.18 the left nav on every screen (ui/navrail.js)
78
+ // From 700px the rail stays beside every page (full screen and the walkthrough
79
+ // too), expanded or collapsed by its toggle or [ and remembered; a short room
80
+ // collapses it for that view without touching the choice. Under 700px a menu
81
+ // button (or a swipe in from the left edge where Safari doesn't own it) opens
82
+ // the nav as a drawer.
83
+ const navStore = createNavStore(window.localStorage)
84
+ const navUi = { shown: null, drawer: false, opener: null, closeT: 0, animT: 0, tipT: 0, tipFor: null, sw: null }
85
+ const navRailQ = window.matchMedia('(min-width: ' + RAIL_MIN + 'px)')
86
+ const navReduced = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
87
+ const navMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
88
+ const navEdgeOk = !safariOwnsEdge(navigator.userAgent, navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches)
89
+ // the room the session panel works in: the window less the (at least slim) rail
90
+ function fsViewport() { return window.innerWidth - (navRailQ.matches ? RAIL_W_SLIM : 0) }
91
+ // the session panel open beside a full-screen chat, else 0
92
+ function navSideWidth() {
93
+ const de = document.documentElement, side = document.getElementById('fsSide')
94
+ if (!side || !de.classList.contains('live-full') || side.classList.contains('collapsed')) return 0
95
+ return Math.round(side.getBoundingClientRect().width) || 0
96
+ }
97
+ function applyNav() {
98
+ const st = navState({ width: window.innerWidth, saved: navStore.collapsed(), side: navSideWidth() })
99
+ const de = document.documentElement
100
+ de.classList.add('nav-ready')
101
+ de.classList.toggle('nav-slim', st.rail && st.collapsed)
102
+ de.classList.toggle('nav-auto', st.rail && st.auto)
103
+ const b = document.getElementById('navToggle')
104
+ if (b) {
105
+ const w = toggleWords(st, navKeyLabel(navMac))
106
+ b.setAttribute('aria-expanded', st.collapsed ? 'false' : 'true')
107
+ b.setAttribute('aria-label', w.label)
108
+ b.setAttribute('aria-disabled', st.auto ? 'true' : 'false')
109
+ b.dataset.tip = w.tip
110
+ const lbl = b.querySelector('.lbl')
111
+ if (lbl) lbl.textContent = st.collapsed ? 'Expand' : 'Collapse'
112
+ if (b.dataset.icon !== w.icon) { b.dataset.icon = w.icon; const svg = b.querySelector('svg'); if (svg) svg.outerHTML = icon(w.icon) }
113
+ if (navUi.tipFor === b) railTipShow(b, true)
114
+ }
115
+ const changed = !navUi.shown || navUi.shown.collapsed !== st.collapsed || navUi.shown.rail !== st.rail
116
+ navUi.shown = st
117
+ if (st.rail && navUi.drawer) closeDrawer(false)
118
+ return changed
119
+ }
120
+ function toggleNav() {
121
+ const st = navUi.shown || navState({ width: window.innerWidth, saved: navStore.collapsed(), side: navSideWidth() })
122
+ if (!st.rail) return false
123
+ if (st.auto) { const b = document.getElementById('navToggle'); if (b) railTipShow(b, true); return false } // no room: say so, change nothing
124
+ const de = document.documentElement
125
+ if (!navReduced()) { de.classList.add('nav-anim'); clearTimeout(navUi.animT); navUi.animT = setTimeout(() => de.classList.remove('nav-anim'), 260) }
126
+ navStore.set(!st.collapsed)
127
+ applyNav()
128
+ return true
129
+ }
130
+ // one floating tip for the rail (the rail scrolls, so a CSS ::after would be cut off at its edge)
131
+ function railTipShow(el, now) {
132
+ const tip = document.getElementById('railTip')
133
+ if (!tip || !el || !navRailQ.matches) return
134
+ const text = el.dataset.tip || ''
135
+ const lbl = el.querySelector('.lbl, .sb-lbl, .label')
136
+ const words = lbl ? lbl.textContent.trim() : ''
137
+ // expanded, the label's already there; the tip shows only when it says more
138
+ if (!text || (!document.documentElement.classList.contains('nav-slim') && text === words)) { railTipHide(); return }
139
+ clearTimeout(navUi.tipT)
140
+ const show = () => {
141
+ if (!el.isConnected) return
142
+ tip.textContent = text
143
+ tip.hidden = false
144
+ const r = el.getBoundingClientRect()
145
+ const rail = document.getElementById('navRail')
146
+ tip.style.left = Math.round((rail ? rail.getBoundingClientRect().right : r.right) + 6) + 'px'
147
+ tip.style.top = Math.round(r.top + r.height / 2) + 'px'
148
+ navUi.tipFor = el
149
+ }
150
+ if (now) show(); else navUi.tipT = setTimeout(show, 350)
151
+ }
152
+ function railTipHide() {
153
+ clearTimeout(navUi.tipT)
154
+ const tip = document.getElementById('railTip')
155
+ if (tip) tip.hidden = true
156
+ navUi.tipFor = null
157
+ }
158
+ const navTipTarget = (t) => t && t.closest && t.closest('#navRail [data-tip]:not(#needsTab)') // Needs you has Quick look on hover
159
+ document.addEventListener('pointerover', (e) => {
160
+ if (e.pointerType === 'touch') return
161
+ const el = navTipTarget(e.target)
162
+ if (!el) return
163
+ if (el !== navUi.tipFor) railTipShow(el, false)
164
+ })
165
+ document.addEventListener('pointerout', (e) => {
166
+ const el = navTipTarget(e.target)
167
+ if (el && !(e.relatedTarget && el.contains(e.relatedTarget)) && document.activeElement !== el) railTipHide()
168
+ })
169
+ document.addEventListener('focusin', (e) => {
170
+ const el = navTipTarget(e.target)
171
+ if (el && el.matches(':focus-visible')) railTipShow(el, true)
172
+ else if (navUi.tipFor && navUi.tipFor !== el) railTipHide()
173
+ })
174
+ document.addEventListener('focusout', (e) => { if (navTipTarget(e.target)) railTipHide() })
175
+ window.addEventListener('scroll', railTipHide, true)
176
+ document.addEventListener('click', (e) => {
177
+ const t = e.target
178
+ if (!t || !t.closest) return
179
+ if (t.closest('#navToggle')) { e.preventDefault(); toggleNav(); return }
180
+ const open = t.closest('[data-nav-drawer]')
181
+ if (open) { e.preventDefault(); if (navUi.drawer) closeDrawer(true); else openDrawer(open); return }
182
+ if (!navUi.drawer) return
183
+ if (t.closest('#navScrim') || t.closest('[data-nd-close]')) { e.preventDefault(); closeDrawer(true); return }
184
+ if (t.closest('#navDrawer .nd-item')) closeDrawer(false) // the link navigates; the page it opens takes focus
185
+ })
186
+ // the drawer (phones): the rail's destinations over the page, a scrim behind
187
+ function navDrawerItems() {
188
+ const items = []
189
+ const take = (a) => {
190
+ if (!a) return
191
+ const lbl = a.querySelector('.lbl')
192
+ const n = a.querySelector('.navcount')
193
+ items.push({ href: a.getAttribute('href'), route: a.dataset.route || '', label: lbl ? lbl.textContent.trim() : a.getAttribute('aria-label') || '', svg: (a.querySelector('svg') || {}).outerHTML || '', on: a.classList.contains('on'), count: n && !n.hidden ? n.textContent.trim() : '' })
194
+ }
195
+ for (const a of document.querySelectorAll('#navRail .tabs a[data-route]')) take(a)
196
+ for (const id of ['newTab', 'wnTab', 'setTab']) take(document.getElementById(id))
197
+ return items
198
+ }
199
+ function openDrawer(opener) {
200
+ if (navRailQ.matches || navUi.drawer) return
201
+ const d = document.getElementById('navDrawer'), s = document.getElementById('navScrim')
202
+ if (!d || !s) return
203
+ clearTimeout(navUi.closeT)
204
+ d.innerHTML = drawerHtml(navDrawerItems())
205
+ navUi.opener = opener || (document.activeElement !== document.body ? document.activeElement : null)
206
+ d.hidden = false; s.hidden = false
207
+ void d.offsetWidth
208
+ d.classList.add('open'); s.classList.add('open')
209
+ document.documentElement.classList.add('nav-drawer-open')
210
+ navUi.drawer = true
211
+ for (const b of document.querySelectorAll('[data-nav-drawer]')) b.setAttribute('aria-expanded', 'true')
212
+ const f = d.querySelector('.nd-item.on') || d.querySelector('.nd-item')
213
+ if (f) f.focus({ preventScroll: true })
214
+ }
215
+ function closeDrawer(restore) {
216
+ if (!navUi.drawer) return
217
+ const d = document.getElementById('navDrawer'), s = document.getElementById('navScrim')
218
+ navUi.drawer = false
219
+ d.classList.remove('open'); s.classList.remove('open')
220
+ document.documentElement.classList.remove('nav-drawer-open')
221
+ for (const b of document.querySelectorAll('[data-nav-drawer]')) b.setAttribute('aria-expanded', 'false')
222
+ const done = () => { if (navUi.drawer) return; d.hidden = true; s.hidden = true; d.innerHTML = '' }
223
+ clearTimeout(navUi.closeT)
224
+ if (navReduced()) done(); else navUi.closeT = setTimeout(done, 200)
225
+ const o = navUi.opener
226
+ navUi.opener = null
227
+ if (restore && o && o.isConnected) o.focus({ preventScroll: true })
228
+ else if (restore) { const m = document.querySelector('[data-nav-drawer]'); if (m && m.offsetParent) m.focus({ preventScroll: true }) }
229
+ }
230
+ document.addEventListener('keydown', (e) => {
231
+ if (navUi.drawer) {
232
+ const d = document.getElementById('navDrawer')
233
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeDrawer(true); return }
234
+ if (e.key === 'Tab') { // focus stays inside while it's open
235
+ const f = [...d.querySelectorAll('a[href], button:not([disabled])')]
236
+ if (!f.length) return
237
+ const i = f.indexOf(document.activeElement)
238
+ if (e.shiftKey && (i <= 0)) { e.preventDefault(); f[f.length - 1].focus() }
239
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
240
+ }
241
+ return
242
+ }
243
+ if (e.defaultPrevented) return
244
+ const t = e.target
245
+ const term = !!(t && t.closest && t.closest('.xterm, .term-dock'))
246
+ const typing = term || !!(t && t.closest && t.closest('input, textarea, select, [contenteditable]'))
247
+ if (!isNavKey(e, { typing, term, mac: navMac }) || !navRailQ.matches) return
248
+ if (document.querySelector('.lo-menu, .cmdk:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden])')) return
249
+ if ([...document.querySelectorAll('[role="dialog"][aria-modal="true"]')].some((d) => d.id !== 'navDrawer' && !d.closest('[hidden]') && d.getClientRects().length)) return
250
+ e.preventDefault()
251
+ toggleNav()
252
+ }, true)
253
+ // a swipe in from the left edge opens it, a swipe left on it closes it (phones; not where Safari's back-swipe owns the edge)
254
+ function navOwnsTouch(x) { return !navRailQ.matches && (navUi.drawer || (navEdgeOk && edgeStart(x))) }
255
+ document.addEventListener('touchstart', (e) => {
256
+ navUi.sw = null
257
+ if (navRailQ.matches || e.touches.length !== 1) return
258
+ const p = e.touches[0]
259
+ if (navUi.drawer) { navUi.sw = { x: p.clientX, y: p.clientY, open: true }; return }
260
+ if (navEdgeOk && edgeStart(p.clientX)) navUi.sw = { x: p.clientX, y: p.clientY, open: false }
261
+ }, { passive: true })
262
+ document.addEventListener('touchend', (e) => {
263
+ const sw = navUi.sw
264
+ navUi.sw = null
265
+ if (!sw || !e.changedTouches.length) return
266
+ const p = e.changedTouches[0]
267
+ const what = swipeIntent(p.clientX - sw.x, p.clientY - sw.y, sw.open)
268
+ if (what === 'open') openDrawer(null)
269
+ else if (what === 'close') closeDrawer(true)
270
+ }, { passive: true })
271
+ {
272
+ let raf = 0
273
+ window.addEventListener('resize', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; railTipHide(); applyNav() }) })
274
+ navRailQ.addEventListener('change', () => { if (navRailQ.matches) closeDrawer(false) })
275
+ }
276
+ applyNav()
277
+
74
278
  const $app = document.getElementById('app')
279
+ // 0.9.18 (ui/scroll-keep.js): a redraw anywhere (a live update, a poll) puts every scroll box, the focus
280
+ // and an open <details> back where you had them; the app's own positioning (stick to bottom, a new page) wins
281
+ const scrollKeep = createScrollKeep()
282
+ scrollKeep.start()
75
283
  // 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
76
284
  const wtUi = { sid: null, scope: new Map(), view: 'story', filter: 'all', data: new Map(), loading: new Set(), error: new Map(), cur: new Map(), code: new Set(), groups: new Set(), pending: [], ask: '', node: null, sig: '', restored: false, fellBack: new Set() }
77
285
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
@@ -1887,7 +2095,7 @@ function paintNeedsCount(n) {
1887
2095
  const c = document.getElementById('needsCount')
1888
2096
  if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1889
2097
  const tab = document.getElementById('needsTab')
1890
- if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
2098
+ if (tab) { tab.classList.toggle('has-needs', !!n); tab.dataset.tip = n ? 'Needs you · ' + n + ' waiting' : 'Needs you' }
1891
2099
  if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1892
2100
  paintNeedsCount.count = n
1893
2101
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
@@ -2320,16 +2528,25 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2320
2528
  // 0.9.17 Walk me through it: in place of the chat (the bar and tabs stay); ‹ Chat goes back
2321
2529
  if (wtUi.sid === s.id && isFullLive(s.id) && !s.agent) center = liveOpenHtml(s, liveV, '') + '<div class="wt-mount" id="wtMount" data-sid="' + esc(s.id) + '"></div></section>'
2322
2530
  // ---- the sidebar
2531
+ // 2026-10-28 one aligned head row: › hide, then the title (two lines at most, the whole
2532
+ // title in its tip), then the actions in one cluster on the first line: ✎ Rename and ⋯
2533
+ // (Regenerate title is the ⋯'s first row; the ↻ beside the chat's own title stays). While a new
2534
+ // title is being made, its spinner sits in the cluster.
2535
+ const fullTitle = sessionTitle(s)
2323
2536
  const title = canRename
2324
- ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" data-tip="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id) }) + '<button type="button" class="rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" data-tip="Rename">' + icon('pencil') + '</button></div>'
2325
- : '<div class="title s-title"><h1>' + titleHtml(sessionTitle(s), esc) + '</h1></div>'
2537
+ ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2538
+ : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2326
2539
  const menu = fsMenuItems(s, mine)
2540
+ const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2327
2541
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
2542
+ const acts0 = busyTitle + (canRename ? '<button type="button" class="rename-pen fs-hact" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" data-tip="Rename">' + icon('pencil') + '</button>' : '') + more
2328
2543
  const where = [s.repoLabel || s.repoName || (s.repo && s.repo.name) || '', branch || ''].filter(Boolean)
2329
- const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' + title + more +
2330
- '<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button></div>' +
2544
+ const wherePath = (s.repo && (s.repo.worktree || s.repo.root)) || s.cwd || ''
2545
+ const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2546
+ '<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
2547
+ title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2331
2548
  '<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div>' +
2332
- (where.length ? '<div class="fs-where">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
2549
+ (where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
2333
2550
  '<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>' + '</div>'
2334
2551
  // the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
2335
2552
  noteOpenAway(s)
@@ -2380,7 +2597,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2380
2597
  side += answeredList(s)
2381
2598
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2382
2599
  const label = fsPeekWords(s)
2383
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(window.innerWidth), viewport: window.innerWidth }, { esc, icon })
2600
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2384
2601
  }
2385
2602
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2386
2603
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2405,6 +2622,8 @@ function fsMenuItems(s, mine) {
2405
2622
  if (reachOf(s) === 'away') return awayMenuItems(s)
2406
2623
  const pinned = !!s.pinned
2407
2624
  return [
2625
+ // 2026-10-28: Regenerate title is the ⋯'s first row (out of the head row, so ✎ and ⋯ line up; the chat's own ↻ stays)
2626
+ mine && !ownerOffline(s) ? { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', attrs: { 'data-id': s.id, 'data-tip': RETITLE_TIP } } : null,
2408
2627
  { action: 'fs-timeline', label: 'Open in Timeline', icon: 'history', attrs: { 'data-id': s.id } },
2409
2628
  { action: 'resume', label: 'Copy resume command', icon: 'copy', attrs: { 'data-id': s.id } },
2410
2629
  s.liveResumeCommand ? { action: 'copy-cmd', label: 'Copy Live resume', icon: 'copy', attrs: { 'data-cmd': s.liveResumeCommand } } : null,
@@ -2426,17 +2645,63 @@ function paintGhost(sid) {
2426
2645
  const s = state.sessions.find((x) => x.id === sid)
2427
2646
  const g = ghostUi.get(sid)
2428
2647
  const show = ghostShows(g, { value: ta.value, idle: ghostIdle(s) }) && !ta.disabled
2648
+ // 2026-10-29 (beta feedback: iOS has no Tab key, and a long suggestion was cut off): on a touch
2649
+ // screen it is a chip above the box instead of grey text in it (paintSugChip)
2650
+ const touch = isTouchUi(window.matchMedia)
2651
+ paintSugChip(box, sid, show && touch ? g : null)
2429
2652
  let el = box.querySelector('.ghost')
2430
- if (!show) { if (el) el.hidden = true; box.classList.remove('has-ghost'); return }
2653
+ if (!show || touch) { if (el) el.hidden = true; box.classList.remove('has-ghost'); if (!show) ta.removeAttribute('aria-description'); return }
2431
2654
  if (!el) { el = document.createElement('div'); el.className = 'ghost'; el.setAttribute('aria-hidden', 'true'); box.appendChild(el) }
2432
2655
  const cs = getComputedStyle(ta)
2433
2656
  Object.assign(el.style, { left: ta.offsetLeft + 'px', top: ta.offsetTop + 'px', width: ta.offsetWidth + 'px', height: ta.offsetHeight + 'px', padding: cs.padding, font: cs.font, lineHeight: cs.lineHeight, inset: 'auto' })
2434
- el.textContent = g.text
2657
+ el.textContent = ''
2658
+ // the words, then the Tab key (always visible: the words are cut, not the key); a long one gets ⋯ with all of it in its tip
2659
+ const t = document.createElement('span'); t.className = 'ghost-t'; t.textContent = g.text; el.appendChild(t)
2435
2660
  const k = document.createElement('span'); k.className = 'ghost-k'; k.textContent = 'Tab'; el.appendChild(k)
2436
2661
  el.hidden = false
2437
2662
  box.classList.add('has-ghost')
2663
+ if (t.scrollWidth > t.clientWidth + 1) {
2664
+ const all = document.createElement('span'); all.className = 'ghost-all'; all.textContent = '⋯'; all.dataset.tip = g.text
2665
+ el.insertBefore(all, k)
2666
+ }
2438
2667
  ta.setAttribute('aria-description', 'Suggested: ' + g.text + '. Press Tab to use it.')
2439
2668
  }
2669
+ // The suggestion on a touch screen: a row above the composer. "↳ Use suggestion" and its words
2670
+ // (two lines; ⌄ or a long-press shows all of it) put it in the box to edit; ✕ dismisses it.
2671
+ // 44px targets. Never sent on its own.
2672
+ function paintSugChip(box, sid, g) {
2673
+ const host = box.parentNode
2674
+ let chip = host && host.querySelector(':scope > .sug-chip[data-sid="' + CSS.escape(sid) + '"]')
2675
+ if (!g) { if (chip) chip.remove(); return }
2676
+ if (chip && chip.dataset.text === g.text) return
2677
+ if (chip) chip.remove()
2678
+ chip = document.createElement('div')
2679
+ chip.className = 'sug-chip'; chip.dataset.sid = sid; chip.dataset.text = g.text
2680
+ chip.setAttribute('role', 'group'); chip.setAttribute('aria-label', 'Suggested message')
2681
+ chip.innerHTML = '<button type="button" class="sug-use"><span class="sug-l"><span aria-hidden="true">↳ </span>Use suggestion</span><span class="sug-text"></span></button>' +
2682
+ '<button type="button" class="sug-more" aria-expanded="false" aria-label="Show all of the suggestion" data-tip="Show all">' + icon('chevron-down') + '</button>' +
2683
+ '<button type="button" class="sug-x" aria-label="Dismiss suggestion" data-tip="Dismiss">' + icon('x') + '</button>'
2684
+ chip.querySelector('.sug-text').textContent = g.text
2685
+ host.insertBefore(chip, box)
2686
+ const text = chip.querySelector('.sug-text'), more = chip.querySelector('.sug-more'), use = chip.querySelector('.sug-use')
2687
+ more.hidden = text.scrollHeight <= text.clientHeight + 1
2688
+ const expand = (on) => { chip.classList.toggle('open', on); more.setAttribute('aria-expanded', String(on)); more.setAttribute('aria-label', on ? 'Show less of the suggestion' : 'Show all of the suggestion'); more.dataset.tip = on ? 'Show less' : 'Show all' }
2689
+ more.addEventListener('click', () => expand(!chip.classList.contains('open')))
2690
+ let press = 0, long = false
2691
+ use.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; long = false; clearTimeout(press); press = setTimeout(() => { long = true; if (!more.hidden) expand(true) }, 500) })
2692
+ for (const t of ['pointerup', 'pointercancel', 'pointerleave']) use.addEventListener(t, () => clearTimeout(press))
2693
+ use.addEventListener('contextmenu', (e) => e.preventDefault())
2694
+ use.addEventListener('click', (e) => {
2695
+ if (long) { long = false; e.preventDefault(); return } // a long-press shows it, it doesn't use it
2696
+ const ta = box.querySelector('textarea.composer-input'), cur = ghostUi.get(sid)
2697
+ if (!ta || !cur) return
2698
+ ta.value = cur.text
2699
+ cur.cleared = true
2700
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft is kept, the box fits, the ↑ lights
2701
+ paintGhost(sid)
2702
+ })
2703
+ chip.querySelector('.sug-x').addEventListener('click', () => { const cur = ghostUi.get(sid); if (cur) cur.cleared = true; paintGhost(sid) })
2704
+ }
2440
2705
  async function loadGhost(s, v) {
2441
2706
  if (!s || s.agent || !isFullLive(s.id) || state.mayAnswer === false) return
2442
2707
  const last = lastReply(v && !v.error ? v.messages : null)
@@ -2495,7 +2760,40 @@ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
2495
2760
  function sessionNeedsStrip(s) {
2496
2761
  // 0.9.14: an asleep Mac's ask isn't live: what it was, muted, and no Allow / Deny
2497
2762
  if (ownerOffline(s)) return '<section class="needs-strip one off-was" aria-label="Was waiting on you"><p class="faint">' + icon('wifi-off') + ' Was waiting on you when ' + esc(s.machine) + ' went offline' + (needsLine(s.needsYou) ? ': “' + esc(needsLine(s.needsYou)) + '”' : '') + '. You can answer once it’s back.</p></section>'
2498
- return '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
2763
+ // 2026-10-29 (beta feedback: on a phone the block covered the message you're answering): it folds to a one-line bar
2764
+ // (the question's words) and back; the choice is kept per session through repaints (needsFold)
2765
+ const folded = needsFold.has(s.id)
2766
+ const peek = needsLine(s.needsYou) || ''
2767
+ return '<section class="needs-strip one' + (folded ? ' folded' : '') + '" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2>' + (peek ? '<span class="needs-peek" aria-hidden="' + !folded + '">' + esc(peek) + '</span>' : '') +
2768
+ '<button type="button" class="needs-fold" data-action="needs-fold" data-id="' + esc(s.id) + '" aria-expanded="' + !folded + '" aria-label="' + (folded ? 'Show Needs you' : 'Fold Needs you to a bar') + '" data-tip="' + (folded ? 'Show' : 'Fold to a bar') + '">' + icon('chevron-down') + '</button></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
2769
+ const needsFold = new Set() // session ids whose Needs-you block is folded to its bar (this page only)
2770
+ // Typing a reply in full screen's Needs you with the keyboard up: the Live composer under it steps aside (one box,
2771
+ // not two; app.css). It comes back a beat after the box loses focus, so the layout doesn't move under the tap that took focus away.
2772
+ {
2773
+ let t = 0
2774
+ document.addEventListener('focusin', (e) => { if (e.target && e.target.closest && e.target.closest('.live-box.full .lf-needs textarea')) { clearTimeout(t); document.documentElement.classList.add('ny-typing') } })
2775
+ document.addEventListener('focusout', (e) => { if (e.target && e.target.closest && e.target.closest('.live-box.full .lf-needs textarea')) { clearTimeout(t); t = setTimeout(() => { if (!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box.full .lf-needs textarea'))) document.documentElement.classList.remove('ny-typing') }, 400) } })
2776
+ }
2777
+ function needsFoldClick(btn) {
2778
+ const sid = btn.dataset.id
2779
+ const on = !needsFold.has(sid)
2780
+ if (on) needsFold.add(sid); else needsFold.delete(sid)
2781
+ const strip = btn.closest('.needs-strip')
2782
+ if (strip) strip.classList.toggle('folded', on)
2783
+ btn.setAttribute('aria-expanded', String(!on))
2784
+ btn.setAttribute('aria-label', on ? 'Show Needs you' : 'Fold Needs you to a bar')
2785
+ btn.dataset.tip = on ? 'Show' : 'Fold to a bar'
2786
+ const peek = strip && strip.querySelector('.needs-peek'); if (peek) peek.setAttribute('aria-hidden', String(!on))
2787
+ }
2788
+ // 2026-10-29: the full-screen thread keeps its end in view when the room under it changes (the keyboard,
2789
+ // Needs you folding or growing): a thread that was at its bottom stays there; one you scrolled up is left alone.
2790
+ function pinLogEnd(ol) {
2791
+ if (!ol || ol._pinEnd || typeof ResizeObserver !== 'function') return
2792
+ const atEnd = () => ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24
2793
+ ol._pinEnd = { at: atEnd() }
2794
+ ol.addEventListener('scroll', () => { ol._pinEnd.at = atEnd() }, { passive: true })
2795
+ new ResizeObserver(() => { if (ol._pinEnd.at && ol.isConnected) ol.scrollTop = ol.scrollHeight }).observe(ol)
2796
+ }
2499
2797
  // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
2500
2798
  // bottom); Claude's answer is the session's own (markdown, like the terminal).
2501
2799
  const liveKeeper = createLiveKeeper()
@@ -2606,26 +2904,27 @@ function exitFull(id) {
2606
2904
  const fsResize = { drag: null, raf: 0 }
2607
2905
  function applyFsWidth(w) {
2608
2906
  const de = document.documentElement
2609
- const n = clampSideWidth(w, window.innerWidth)
2907
+ const n = clampSideWidth(w, fsViewport())
2610
2908
  if (n === SIDE_W_DEFAULT && fsWidth.stored() == null) de.style.removeProperty('--fs-uw')
2611
2909
  else de.style.setProperty('--fs-uw', n + 'px')
2612
- for (const h of document.querySelectorAll('.fs-resize')) { h.setAttribute('aria-valuenow', String(n)); h.setAttribute('aria-valuemax', String(sideMax(window.innerWidth))) }
2910
+ for (const h of document.querySelectorAll('.fs-resize')) { h.setAttribute('aria-valuenow', String(n)); h.setAttribute('aria-valuemax', String(sideMax(fsViewport()))) }
2911
+ applyNav()
2613
2912
  return n
2614
2913
  }
2615
- applyFsWidth(fsWidth.shown(window.innerWidth))
2616
- window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(window.innerWidth)) })
2914
+ applyFsWidth(fsWidth.shown(fsViewport()))
2915
+ window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(fsViewport())) })
2617
2916
  document.addEventListener('pointerdown', (e) => {
2618
2917
  const h = e.target.closest && e.target.closest('.fs-resize')
2619
2918
  if (!h || e.button !== 0 || window.innerWidth < WIDE_MIN) return
2620
2919
  e.preventDefault()
2621
2920
  try { h.setPointerCapture(e.pointerId) } catch {}
2622
- fsResize.drag = { id: e.pointerId, x: e.clientX, w: fsWidth.shown(window.innerWidth), now: null, h }
2921
+ fsResize.drag = { id: e.pointerId, x: e.clientX, w: fsWidth.shown(fsViewport()), now: null, h }
2623
2922
  document.documentElement.classList.add('fs-resizing')
2624
2923
  })
2625
2924
  document.addEventListener('pointermove', (e) => {
2626
2925
  const d = fsResize.drag
2627
2926
  if (!d || e.pointerId !== d.id) return
2628
- d.now = clampSideWidth(d.w + (d.x - e.clientX), window.innerWidth) // the handle is the panel's left edge: left is wider
2927
+ d.now = clampSideWidth(d.w + (d.x - e.clientX), fsViewport()) // the handle is the panel's left edge: left is wider
2629
2928
  if (!fsResize.raf) fsResize.raf = requestAnimationFrame(() => { fsResize.raf = 0; if (fsResize.drag && fsResize.drag.now != null) applyFsWidth(fsResize.drag.now) })
2630
2929
  })
2631
2930
  const fsResizeEnd = (e) => {
@@ -2634,7 +2933,7 @@ const fsResizeEnd = (e) => {
2634
2933
  fsResize.drag = null
2635
2934
  document.documentElement.classList.remove('fs-resizing')
2636
2935
  try { d.h.releasePointerCapture(d.id) } catch {}
2637
- if (d.now != null && d.now !== d.w) applyFsWidth(fsWidth.set(d.now, window.innerWidth))
2936
+ if (d.now != null && d.now !== d.w) applyFsWidth(fsWidth.set(d.now, fsViewport()))
2638
2937
  }
2639
2938
  document.addEventListener('pointerup', fsResizeEnd)
2640
2939
  document.addEventListener('pointercancel', fsResizeEnd)
@@ -2642,7 +2941,7 @@ document.addEventListener('dblclick', (e) => { if (e.target.closest && e.target.
2642
2941
  document.addEventListener('keydown', (e) => {
2643
2942
  const h = e.target.closest && e.target.closest('.fs-resize')
2644
2943
  if (!h || e.metaKey || e.ctrlKey || e.altKey) return
2645
- const cur = fsWidth.shown(window.innerWidth)
2944
+ const cur = fsWidth.shown(fsViewport())
2646
2945
  let next = null
2647
2946
  if (e.key === 'ArrowLeft') next = cur + SIDE_W_STEP
2648
2947
  else if (e.key === 'ArrowRight') next = cur - SIDE_W_STEP
@@ -2651,7 +2950,7 @@ document.addEventListener('keydown', (e) => {
2651
2950
  else if (e.key === 'Enter') { e.preventDefault(); fsWidth.reset(); applyFsWidth(SIDE_W_DEFAULT); return }
2652
2951
  if (next == null) return
2653
2952
  e.preventDefault()
2654
- applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(window.innerWidth), window.innerWidth))
2953
+ applyFsWidth(fsWidth.set(Number.isFinite(next) ? next : sideMax(fsViewport()), fsViewport()))
2655
2954
  })
2656
2955
  // 0.9.16 the session panel: open, collapsed to its rail (⌘\, remembered), or on a phone a sheet
2657
2956
  function fsSideClasses() {
@@ -2660,6 +2959,7 @@ function fsSideClasses() {
2660
2959
  de.classList.toggle('fs-on', !!side)
2661
2960
  de.classList.toggle('fs-collapsed', !!side && side.classList.contains('collapsed'))
2662
2961
  de.classList.toggle('fs-sheet-up', !!side && side.classList.contains('sheet-up'))
2962
+ applyNav() // 0.9.18: a session panel beside the chat can leave too little room for the expanded rail
2663
2963
  }
2664
2964
  function toggleFsSide(force) {
2665
2965
  const side = document.getElementById('fsSide')
@@ -2730,6 +3030,7 @@ document.addEventListener('keydown', (ev) => {
2730
3030
  function setFullClass(on) {
2731
3031
  document.documentElement.classList.toggle('live-full', !!on)
2732
3032
  if (on) fitFullViewport()
3033
+ applyNav()
2733
3034
  }
2734
3035
  // The window as you see it: on a phone the keyboard takes the bottom of it,
2735
3036
  // and the composer stays just above the keyboard.
@@ -2758,6 +3059,7 @@ function fullAfterRender(sid) {
2758
3059
  paintLfTabs()
2759
3060
  const ol = box.querySelector('.live-log')
2760
3061
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
3062
+ pinLogEnd(ol)
2761
3063
  } else if (fullUi.back === sid) {
2762
3064
  fullUi.back = null
2763
3065
  const b = document.getElementById('live')
@@ -3354,6 +3656,7 @@ async function refreshLive(reason) {
3354
3656
  const r = liveKeeper.after(box)
3355
3657
  hydrateAttThumbs()
3356
3658
  if (r) liveJumpButton(r.thread, liveKeeper.unseen)
3659
+ if (box.classList.contains('full')) pinLogEnd(fresh.querySelector('.live-log'))
3357
3660
  }
3358
3661
  }
3359
3662
  const c = sessionCache.get(id); if (c) c.live = v
@@ -6637,10 +6940,10 @@ async function refreshHealth() {
6637
6940
  // the header shows this Mac's name; the rest is in the status popover (v0.5.9)
6638
6941
  const summary = h.sessionsOpen + ' open · ' + h.machine + ' · ' + h.summarizer
6639
6942
  el.querySelector('.label').textContent = h.machine
6640
- el.title = summary
6943
+ el.dataset.tip = summary
6641
6944
  el.setAttribute('aria-label', 'Status: ' + summary)
6642
6945
  const brand = document.getElementById('brand')
6643
- if (brand) brand.title = 'Leftoff v' + h.version + (h.update && h.update.available ? ' · update available: v' + (h.update.remoteVersion || '?') : '')
6946
+ if (brand) brand.dataset.tip = 'Leftoff v' + h.version + (h.update && h.update.available ? ' · update available: v' + (h.update.remoteVersion || '?') : '')
6644
6947
  paintTopDot()
6645
6948
  if (statusOpen()) paintStatus()
6646
6949
  paintNeedsCount(needsCountOf(h))
@@ -6656,8 +6959,8 @@ async function refreshHealth() {
6656
6959
  if (updateBar) updateBar.check(null) // a failed check changes nothing
6657
6960
  el.className = 'health down'
6658
6961
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
6659
- el.title = el.querySelector('.label').textContent
6660
- el.setAttribute('aria-label', 'Status: ' + el.title)
6962
+ el.dataset.tip = el.querySelector('.label').textContent
6963
+ el.setAttribute('aria-label', 'Status: ' + el.dataset.tip)
6661
6964
  }
6662
6965
  }
6663
6966
 
@@ -6667,7 +6970,7 @@ async function refreshHealth() {
6667
6970
  // Settings as icons. Nothing wraps or is cut off from 900px up.
6668
6971
 
6669
6972
  const isMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
6670
- { const k = document.getElementById('searchKbd'); if (k) k.textContent = isMac ? '⌘K' : 'Ctrl K'; const b = document.getElementById('searchBtn'); if (b) b.title = 'Search (' + (isMac ? '⌘K' : 'Ctrl+K') + ' or /)' }
6973
+ { const k = document.getElementById('searchKbd'); if (k) k.textContent = isMac ? '⌘K' : 'Ctrl K'; const b = document.getElementById('searchBtn'); if (b) b.dataset.tip = 'Search (' + (isMac ? '⌘K' : 'Ctrl+K') + ' or /)' }
6671
6974
 
6672
6975
  // The gear's dot: anything a Settings section would flag (an update, a failed digest, a feed that can't be pulled, sync trouble).
6673
6976
  function paintTopDot() {
@@ -6681,7 +6984,7 @@ function paintTopDot() {
6681
6984
  el.hidden = !all.length || (!warn && !(h.update && h.update.available))
6682
6985
  el.className = 'topdot' + (warn ? ' warn' : '')
6683
6986
  const why = all.flatMap((x) => x.why)
6684
- document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6987
+ document.getElementById('setTab').dataset.tip = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
6685
6988
  paintMoreDots()
6686
6989
  }
6687
6990
 
@@ -7160,13 +7463,13 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7160
7463
  tip.classList.add('on')
7161
7464
  }, delay)
7162
7465
  }
7163
- const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7466
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
7164
7467
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7165
7468
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7166
7469
  document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
7167
7470
  document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
7168
7471
  for (const ev of ['pointerdown', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
7169
- window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
7472
+ window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil && !scrollKeep.quiet()) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
7170
7473
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
7171
7474
  // 0.9.18 touch: a long press shows the tip (the tap it ends with doesn't follow the link)
7172
7475
  let lp = 0, lpd = false
@@ -7184,7 +7487,7 @@ function wtStuck(sc) {
7184
7487
  }
7185
7488
  document.addEventListener('scroll', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('wt-scroll')) wtStuck(t) }, true)
7186
7489
  // 0.9.18 Walk me through it and Catch me up explain themselves (ui/walk-pop.js): hover, focus, long-press, the (i)
7187
- loPop = initPops({ esc, icon, contentFor: (kind, sid, el) => popContent(kind, { size: kind === 'wt' ? (wtSize.get(sid) || {}).data || null : null, away: !!(el && el.dataset.away) }) })
7490
+ loPop = initPops({ esc, icon, quiet: () => scrollKeep.quiet(), contentFor: (kind, sid, el) => popContent(kind, { size: kind === 'wt' ? (wtSize.get(sid) || {}).data || null : null, away: !!(el && el.dataset.away) }) })
7188
7491
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
7189
7492
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
7190
7493
  for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
@@ -7206,6 +7509,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
7206
7509
  document.addEventListener('touchstart', (e) => {
7207
7510
  sw = null
7208
7511
  if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
7512
+ if (navOwnsTouch(e.touches[0].clientX)) return // 0.9.18: the left edge (and the open drawer) belong to the nav drawer
7209
7513
  const t = e.target
7210
7514
  // a table (even one that fits) or a code block is for reading and scrolling sideways, not for changing sessions
7211
7515
  if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .lf-tabs, .moremenu, table, .doc-table, pre, #wtDeck, .wt-dnav') || scrollsSideways(t)) return
@@ -8440,6 +8744,7 @@ document.addEventListener('click', async (ev) => {
8440
8744
  if (action === 'live-earlier') return liveEarlierClick(btn)
8441
8745
  if (action === 'quick-reply') return quickReplyClick(btn)
8442
8746
  if (action === 'need-more') return needMoreClick(btn)
8747
+ if (action === 'needs-fold') return needsFoldClick(btn)
8443
8748
  if (action === 'need-dismiss') return needDismissClick(btn)
8444
8749
  // ---- To do and Files (0.7.0)
8445
8750
  if (action === 'todo-restore' || action === 'todo-restore-all') {
@@ -8542,7 +8847,7 @@ document.addEventListener('click', async (ev) => {
8542
8847
  if (action === 'title-auto') toast('Naming it from the latest messages…', 20000)
8543
8848
  await runRetitle(id, { api, busy: retitling, render, toast })
8544
8849
  } else if (action === 'rename') {
8545
- const box = btn.closest('.title')
8850
+ const box = btn.closest('.title') || (btn.closest('.fs-head') && btn.closest('.fs-head').querySelector('.title')) // the panel's ✎ sits in the head's action cluster
8546
8851
  if (!box || box.querySelector('input')) return
8547
8852
  const current = s ? s.title || '' : btn.dataset.title || ''
8548
8853
  box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">' +
package/ui/icons.js CHANGED
@@ -3,6 +3,9 @@
3
3
  // so they follow light and dark; always aria-hidden next to a text label.
4
4
  // index.html carries the nav's copies inline (no script needed to draw them).
5
5
  export const ICONS = {
6
+ "panel-left-close": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m16 15-3-3 3-3\"/>",
7
+ "panel-left-open": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m14 9 3 3-3 3\"/>",
8
+ "menu": "<path d=\"M4 5h16\"/> <path d=\"M4 12h16\"/> <path d=\"M4 19h16\"/>",
6
9
  "arrow-up": "<path d=\"m5 12 7-7 7 7\"/> <path d=\"M12 19V5\"/>",
7
10
  "eye": "<path d=\"M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z\"/> <circle cx=\"12\" cy=\"12\" r=\"3\"/>",
8
11
  "circle-help": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3\"/> <path d=\"M12 17h.01\"/>",