@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/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.
|
|
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 || '') + '"
|
|
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
|
|
2330
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
2616
|
-
window.addEventListener('resize', () => { if (!fsResize.drag) applyFsWidth(fsWidth.shown(
|
|
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(
|
|
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),
|
|
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,
|
|
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(
|
|
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(
|
|
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.
|
|
6943
|
+
el.dataset.tip = summary
|
|
6641
6944
|
el.setAttribute('aria-label', 'Status: ' + summary)
|
|
6642
6945
|
const brand = document.getElementById('brand')
|
|
6643
|
-
if (brand) brand.
|
|
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.
|
|
6660
|
-
el.setAttribute('aria-label', 'Status: ' + el.
|
|
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.
|
|
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').
|
|
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\"/>",
|