@leftoff/cli 0.9.3 → 0.9.5
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 +15 -0
- package/bin/leftoff.js +310 -289
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +84 -10
- package/ui/app.js +181 -59
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/picker.js +84 -18
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
package/ui/app.js
CHANGED
|
@@ -4,6 +4,9 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
+
import { changedKeys } from './settings-dirty.js'
|
|
8
|
+
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
9
|
+
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
7
10
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
|
|
8
11
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
12
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -11,7 +14,7 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
11
14
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
15
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
16
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
14
|
-
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
|
|
17
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
15
18
|
const $app = document.getElementById('app')
|
|
16
19
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
17
20
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -128,14 +131,23 @@ function cpExtras(cp) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
// more: show the informative / long sections, folded (open: unfolded).
|
|
131
|
-
|
|
134
|
+
// md (0.9.5): the session page's full view renders a markdown checkpoint (a
|
|
135
|
+
// model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
|
|
136
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false } = {}) {
|
|
132
137
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
133
138
|
const you = /^YOU:/i.test(cp.next || '')
|
|
134
139
|
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
135
|
-
|
|
136
|
-
'<
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
const dd = (t, cls = '') => {
|
|
141
|
+
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
142
|
+
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
143
|
+
const long = (cp.format === 'markdown' || looksMd(t)) ? plainT.length > 160 : plainT.length > 600
|
|
144
|
+
const c = (cls + (!md && long ? ' clamp' : '')).trim()
|
|
145
|
+
return '<dd' + (c ? ' class="' + c + '"' : '') + '>' + fmtCode(plainT) + '</dd>'
|
|
146
|
+
}
|
|
147
|
+
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
148
|
+
'<dt>Doing</dt>' + dd(cp.doing) +
|
|
149
|
+
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
150
|
+
'<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) +
|
|
139
151
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) : '') + '</div>' : '') +
|
|
140
152
|
'</dl>' +
|
|
141
153
|
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp) + '</div></details>' : '')
|
|
@@ -420,17 +432,19 @@ function copyResumeIcon(s) {
|
|
|
420
432
|
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
421
433
|
}
|
|
422
434
|
// items: [{ action, label, icon, title, attrs }]
|
|
423
|
-
// 0.9.2: an item may also be { note } (a plain line, no button)
|
|
424
|
-
//
|
|
435
|
+
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
436
|
+
// 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
|
|
437
|
+
// at its right edge opens a second page of items (e.g. the checkpoint lengths,
|
|
438
|
+
// each with { sub, on }).
|
|
425
439
|
function moreMenu(s, items, { project = true, title = '' } = {}) {
|
|
426
440
|
items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
|
|
427
441
|
if (!items.length) return ''
|
|
428
442
|
const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
429
|
-
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + '>' +
|
|
443
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
|
|
430
444
|
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
|
|
431
445
|
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
432
446
|
'<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
|
|
433
|
-
: it.
|
|
447
|
+
: it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
|
|
434
448
|
}
|
|
435
449
|
function openCardMenu(btn) {
|
|
436
450
|
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
@@ -440,12 +454,12 @@ function openCardMenu(btn) {
|
|
|
440
454
|
const toItem = (b) => {
|
|
441
455
|
const svg = b.querySelector('svg')
|
|
442
456
|
const attrs = {}
|
|
443
|
-
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub') || a.name === 'title') attrs[a.name] = a.value
|
|
444
|
-
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}) }
|
|
457
|
+
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
|
|
458
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
|
|
445
459
|
}
|
|
446
460
|
const items = [...tpl.content.children].map((el) => {
|
|
447
461
|
if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
|
|
448
|
-
if (el.classList.contains('more-
|
|
462
|
+
if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
|
|
449
463
|
return el.tagName === 'BUTTON' ? toItem(el) : null
|
|
450
464
|
}).filter(Boolean)
|
|
451
465
|
lastMenuAnchor = btn
|
|
@@ -463,12 +477,36 @@ function reachOf(s) {
|
|
|
463
477
|
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
464
478
|
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
465
479
|
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
466
|
-
// Checkpoint now
|
|
480
|
+
// 0.9.5: "Checkpoint now" in flight stays on screen (ui/writing.js) until it lands
|
|
481
|
+
// or fails; the checkpoint that lands gets a short highlight.
|
|
482
|
+
state.writing = {}
|
|
483
|
+
state.cpFresh = {}
|
|
484
|
+
function writingFor(s) {
|
|
485
|
+
const w = reconcileWriting(state.writing, s)
|
|
486
|
+
if (w && w.landed) { state.cpFresh[w.landed] = Date.now(); return null }
|
|
487
|
+
return w
|
|
488
|
+
}
|
|
489
|
+
const isFresh = (cp) => !!(cp && state.cpFresh[cp.id] && Date.now() - state.cpFresh[cp.id] < FRESH_MS)
|
|
490
|
+
function writingLine(s, w, cls = '') {
|
|
491
|
+
const busy = w.phase === 'writing' || w.phase === 'syncing'
|
|
492
|
+
return '<div class="cp-writing ' + w.phase + (cls ? ' ' + cls : '') + '" role="' + (w.phase === 'failed' ? 'alert' : 'status') + '" data-writing="' + esc(s.id) + '">' +
|
|
493
|
+
(busy ? '<span class="spin" aria-hidden="true"></span>' : w.phase === 'failed' ? icon('info') : icon('history')) +
|
|
494
|
+
'<span class="wtext">' + esc(writingText(w, lengthLabel)) + '</span>' +
|
|
495
|
+
(busy ? '' : '<button type="button" class="icon wx" data-action="writing-dismiss" data-id="' + esc(s.id) + '" aria-label="Dismiss" title="Dismiss">' + icon('x') + '</button>') + '</div>'
|
|
496
|
+
}
|
|
497
|
+
// The card's checkpoint area: the writing line above the old checkpoint (dimmed while it's written).
|
|
498
|
+
function cpArea(s, block) {
|
|
499
|
+
const w = writingFor(s)
|
|
500
|
+
if (!w) return isFresh(s.latestCheckpoint) ? '<div class="cp-fresh">' + block + '</div>' : block
|
|
501
|
+
return writingLine(s, w) + (isWriting(state.writing, s.id) ? '<div class="cp-stale" aria-hidden="true">' + block + '</div>' : block)
|
|
502
|
+
}
|
|
503
|
+
// 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
|
|
504
|
+
// Informative / Long, each with its line, your default ticked.
|
|
467
505
|
function checkpointItems() {
|
|
468
506
|
const def = defaultLength()
|
|
469
507
|
return [
|
|
470
|
-
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus',
|
|
471
|
-
|
|
508
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
|
|
509
|
+
more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
|
|
472
510
|
]
|
|
473
511
|
}
|
|
474
512
|
// The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
|
|
@@ -502,6 +540,11 @@ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' ||
|
|
|
502
540
|
// length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
|
|
503
541
|
function checkpointSplit(s) {
|
|
504
542
|
const def = defaultLength()
|
|
543
|
+
if (isWriting(state.writing, s.id)) { // 0.9.5: busy until the checkpoint lands
|
|
544
|
+
return '<span class="split is-writing" role="group" aria-label="Checkpoint now" aria-busy="true">' +
|
|
545
|
+
'<button type="button" class="btn primary split-main" disabled aria-disabled="true" data-id="' + esc(s.id) + '"><span class="spin" aria-hidden="true"></span>Writing…</button>' +
|
|
546
|
+
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
547
|
+
}
|
|
505
548
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
506
549
|
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
|
|
507
550
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
@@ -532,7 +575,7 @@ function card(s) {
|
|
|
532
575
|
'</div>' + pill(s.status) + '</div>' +
|
|
533
576
|
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
534
577
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
535
|
-
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
578
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
536
579
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
537
580
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
538
581
|
copyResumeIcon(s) +
|
|
@@ -949,7 +992,7 @@ function parkedCard(s, viewer) {
|
|
|
949
992
|
const p = s.parked || {}
|
|
950
993
|
const open = s.status !== 'ended' && s.live !== 'closed'
|
|
951
994
|
const cp = s.latestCheckpoint
|
|
952
|
-
const note = p.note || (cp && cp.next) || ''
|
|
995
|
+
const note = p.note || (cp && plainCp(cp.next)) || ''
|
|
953
996
|
const main = open ? goButton(s) : resumeButton(s, viewer)
|
|
954
997
|
return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
955
998
|
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
|
|
@@ -1203,8 +1246,12 @@ async function viewSession(id) {
|
|
|
1203
1246
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
1204
1247
|
html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
|
|
1205
1248
|
const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
|
|
1206
|
-
|
|
1207
|
-
|
|
1249
|
+
// 0.9.5: the one being written, at the top of the list until it lands
|
|
1250
|
+
const wr = writingFor(s)
|
|
1251
|
+
const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
|
|
1252
|
+
const live = s.checkpoints.filter((cp) => !cp.doubt)
|
|
1253
|
+
if (!live.length && !wrRow) html += '<div class="empty">No checkpoints yet.</div>'
|
|
1254
|
+
else html += '<div class="timeline">' + wrRow + cpTimeline(live, newestCp) + '</div>'
|
|
1208
1255
|
// 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
|
|
1209
1256
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
|
|
1210
1257
|
if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
@@ -1215,9 +1262,9 @@ async function viewSession(id) {
|
|
|
1215
1262
|
}
|
|
1216
1263
|
function cpTimeline(list, newestCp) {
|
|
1217
1264
|
return list.map((cp) =>
|
|
1218
|
-
'<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
|
|
1265
|
+
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
|
|
1219
1266
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
|
|
1220
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
|
|
1267
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
|
|
1221
1268
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
1222
1269
|
(cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
|
|
1223
1270
|
'</div>').join('')
|
|
@@ -1762,8 +1809,12 @@ function onPull(v) {
|
|
|
1762
1809
|
const was = state.pulls[v.tag]
|
|
1763
1810
|
state.pulls[v.tag] = v
|
|
1764
1811
|
if (state.route && state.route.name === 'settings') {
|
|
1765
|
-
if (v.done && (!was || !was.done) && v.use) {
|
|
1766
|
-
|
|
1812
|
+
if (v.done && (!was || !was.done) && v.use) {
|
|
1813
|
+
const m = document.getElementById('f-model'); if (m) m.value = v.tag; const sel = document.getElementById('f-mode'); if (sel) sel.value = 'ollama'
|
|
1814
|
+
// the daemon switched the summarizer itself: that's saved, not a change to save (0.9.5)
|
|
1815
|
+
if (saveUi.base) { saveUi.base.provider = 'ollama'; saveUi.base.model = v.tag; saveUi.base.baseUrl = settingsValues().baseUrl }
|
|
1816
|
+
}
|
|
1817
|
+
paintModels(); paintSave()
|
|
1767
1818
|
}
|
|
1768
1819
|
if (v.done && (!was || !was.done)) toast(v.use ? 'Downloaded ' + v.tag + '. Checkpoints now summarize with it.' : 'Downloaded ' + v.tag, 6000)
|
|
1769
1820
|
if (v.error && (!was || !was.error)) toast('Couldn’t download ' + v.tag + ': ' + v.error, 10000)
|
|
@@ -1801,7 +1852,7 @@ async function tryLength() {
|
|
|
1801
1852
|
try {
|
|
1802
1853
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
1803
1854
|
box.innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
|
|
1804
|
-
cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1855
|
+
cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1805
1856
|
} catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
1806
1857
|
}
|
|
1807
1858
|
|
|
@@ -2161,6 +2212,63 @@ function phonePane(relay, remote, unavailable) {
|
|
|
2161
2212
|
return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
|
|
2162
2213
|
}
|
|
2163
2214
|
|
|
2215
|
+
// Settings → Save (0.9.5): Save is on only while the form differs from what was
|
|
2216
|
+
// last saved (ui/settings-dirty.js), so a missed "Saved" toast can't leave you
|
|
2217
|
+
// guessing. The values are exactly what Save sends.
|
|
2218
|
+
const saveUi = { base: null, saving: false, timer: null }
|
|
2219
|
+
function settingsValues() {
|
|
2220
|
+
const $ = (id) => document.getElementById(id)
|
|
2221
|
+
const val = (id) => { const el = $(id); return el && el.value != null ? String(el.value) : '' }
|
|
2222
|
+
const on = (id) => { const el = $(id); return !!(el && el.checked) }
|
|
2223
|
+
const prov = val('f-mode') || 'auto'
|
|
2224
|
+
const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
|
|
2225
|
+
const ra = $('f-remote-answers')
|
|
2226
|
+
return {
|
|
2227
|
+
provider: prov,
|
|
2228
|
+
model: (f.model && (val('f-model-other').trim() || val('f-model').trim())) || 'haiku',
|
|
2229
|
+
baseUrl: (f.base && val('f-base').trim()) || null,
|
|
2230
|
+
key: f.key ? val('f-key').trim() : '',
|
|
2231
|
+
dormantAfterMin: Number(val('f-dormant')),
|
|
2232
|
+
minGapMin: Number(val('f-gap')),
|
|
2233
|
+
digest: {
|
|
2234
|
+
time: val('f-time') || null,
|
|
2235
|
+
days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
|
|
2236
|
+
skipEmpty: on('f-skip'),
|
|
2237
|
+
timeZone: val('f-tz') || null,
|
|
2238
|
+
},
|
|
2239
|
+
length: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative',
|
|
2240
|
+
updates: on('f-updates'),
|
|
2241
|
+
notify: on('f-notify'),
|
|
2242
|
+
remoteAnswers: ra && !ra.disabled ? ra.checked : null,
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const settingsChanged = () => saveUi.base && document.getElementById('settingsForm') ? changedKeys(saveUi.base, settingsValues()) : []
|
|
2246
|
+
// Where each value lives, to name the sections with unsaved changes.
|
|
2247
|
+
const SAVE_FIELD = { provider: 'f-mode', model: 'f-model', baseUrl: 'f-base', key: 'f-key', dormantAfterMin: 'f-dormant', minGapMin: 'f-gap', digest: 'f-time', length: 'f-length-example', updates: 'f-updates', notify: 'f-notify', remoteAnswers: 'f-remote-answers' }
|
|
2248
|
+
function paintSave() {
|
|
2249
|
+
const box = document.getElementById('setSave')
|
|
2250
|
+
const btn = box && box.querySelector('button[type=submit]')
|
|
2251
|
+
if (!btn) return
|
|
2252
|
+
const keys = settingsChanged()
|
|
2253
|
+
const dirty = keys.length > 0
|
|
2254
|
+
btn.disabled = !dirty || saveUi.saving
|
|
2255
|
+
btn.setAttribute('aria-disabled', String(btn.disabled))
|
|
2256
|
+
btn.title = dirty ? '' : 'Nothing to save'
|
|
2257
|
+
box.classList.toggle('dirty', dirty)
|
|
2258
|
+
const note = document.getElementById('setSaveNote')
|
|
2259
|
+
if (!note) return
|
|
2260
|
+
const secs = []
|
|
2261
|
+
for (const k of keys) {
|
|
2262
|
+
const el = document.getElementById(SAVE_FIELD[k] || '')
|
|
2263
|
+
const pane = el && el.closest('.set-pane')
|
|
2264
|
+
const sec = pane && SETTINGS.find((x) => x.key === pane.dataset.sec)
|
|
2265
|
+
if (sec && !secs.includes(sec.label)) secs.push(sec.label)
|
|
2266
|
+
}
|
|
2267
|
+
note.textContent = saveUi.saving ? 'Saving…' : dirty ? 'Unsaved changes' + (secs.length ? ' in ' + secs.join(', ') : '') : 'Everything’s saved'
|
|
2268
|
+
}
|
|
2269
|
+
// After the click/key that changed something has done its work (a model card, a day button).
|
|
2270
|
+
function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(paintSave, 0) }
|
|
2271
|
+
|
|
2164
2272
|
async function viewSettings() {
|
|
2165
2273
|
const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
|
|
2166
2274
|
state.relay = relay
|
|
@@ -2221,7 +2329,7 @@ async function viewSettings() {
|
|
|
2221
2329
|
'<div class="set-main"><div class="set-head"><a class="set-back" href="#/settings">' + icon('chevron-left') + 'Settings</a><h2 id="setTitle"></h2><div class="sub" id="setSub"></div></div>' +
|
|
2222
2330
|
'<form class="form" id="settingsForm" autocomplete="off">' +
|
|
2223
2331
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
2224
|
-
'<div class="full set-save" id="setSave"><span class="faint">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
2332
|
+
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
2225
2333
|
showSettingsSection(r)
|
|
2226
2334
|
paintSettingsDots()
|
|
2227
2335
|
syncProviderFields()
|
|
@@ -2251,6 +2359,10 @@ async function viewSettings() {
|
|
|
2251
2359
|
if (ev.key === 'Escape' && f.value) { ev.preventDefault(); f.value = ''; settingsUi.q = ''; settingsFilter() }
|
|
2252
2360
|
if (ev.key === 'Enter') { ev.preventDefault(); const k = settingsFilter(); if (k) location.hash = '#/settings/' + k }
|
|
2253
2361
|
})
|
|
2362
|
+
// 0.9.5: what's on screen now is what's saved; Save comes on with the first change.
|
|
2363
|
+
saveUi.base = settingsValues(); saveUi.saving = false; paintSave()
|
|
2364
|
+
const form = document.getElementById('settingsForm')
|
|
2365
|
+
for (const t of ['input', 'change', 'click', 'keyup']) form.addEventListener(t, queueSave)
|
|
2254
2366
|
}
|
|
2255
2367
|
|
|
2256
2368
|
// ------------------------------------------------------------ sync and other machines (v0.5.2)
|
|
@@ -3166,7 +3278,7 @@ function searchGroups(q, r) {
|
|
|
3166
3278
|
if ((r.lists || []).length) groups.push(['Lists', r.lists.slice(0, 4).map((l) => ({ href: listHref(l.ref), title: l.name, sub: [l.repo, l.progress].filter(Boolean).join(' · '), icon: 'list-checks' }))])
|
|
3167
3279
|
if ((r.todos || []).length) groups.push(['To-dos', r.todos.slice(0, 4).map((t) => ({ href: t.sessionId ? '#/s/' + encodeURIComponent(t.sessionId) : '#/waiting', title: t.text, sub: t.sessionTitle || '', icon: 'circle-check' }))])
|
|
3168
3280
|
if ((r.files || []).length) groups.push(['Files', r.files.slice(0, 5).map((f) => ({ href: '#/s/' + encodeURIComponent(f.sessionId), title: f.name, sub: f.sessionTitle || '', icon: 'file-text' }))])
|
|
3169
|
-
if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: cp.doing || cp.next || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
|
|
3281
|
+
if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: plainCp(cp.doing || cp.next) || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
|
|
3170
3282
|
}
|
|
3171
3283
|
if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
|
|
3172
3284
|
if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
|
|
@@ -3508,7 +3620,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3508
3620
|
if (btn.dataset.needsDl) { toast('Download it first: press Download & use'); return }
|
|
3509
3621
|
document.getElementById('f-model').value = btn.dataset.tag
|
|
3510
3622
|
const o = document.getElementById('f-model-other'); if (o) o.value = ''
|
|
3511
|
-
return
|
|
3623
|
+
paintModels(); return paintSave()
|
|
3512
3624
|
}
|
|
3513
3625
|
if (action === 'ollama-start') {
|
|
3514
3626
|
btn.disabled = true; btn.textContent = 'Starting…'
|
|
@@ -3528,11 +3640,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
3528
3640
|
if (action === 'day-toggle') {
|
|
3529
3641
|
const on = btn.getAttribute('aria-pressed') === 'true'
|
|
3530
3642
|
if (on && document.querySelectorAll('.days .day[aria-pressed="true"]').length === 1) { toast('Pick at least one day'); return }
|
|
3531
|
-
btn.setAttribute('aria-pressed', String(!on)); return
|
|
3643
|
+
btn.setAttribute('aria-pressed', String(!on)); return paintSave()
|
|
3532
3644
|
}
|
|
3533
3645
|
if (action === 'days-preset') {
|
|
3534
3646
|
const want = btn.dataset.days === 'weekdays' ? ['mon', 'tue', 'wed', 'thu', 'fri'] : DAY_KEYS.map(([k]) => k)
|
|
3535
|
-
document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return
|
|
3647
|
+
document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return paintSave()
|
|
3536
3648
|
}
|
|
3537
3649
|
if (action === 'repo-all') {
|
|
3538
3650
|
state.moreOpen = false
|
|
@@ -3628,17 +3740,29 @@ document.addEventListener('click', async (ev) => {
|
|
|
3628
3740
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3629
3741
|
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3630
3742
|
} else if (action === 'checkpoint') {
|
|
3631
|
-
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
|
|
3743
|
+
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it.
|
|
3744
|
+
// 0.9.5: "Writing checkpoint…" stays on the card and the session page until it lands or fails.
|
|
3632
3745
|
const length = btn.dataset.length || null
|
|
3633
3746
|
const away = s && !s.isLocal ? s.machine : null
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3747
|
+
if (isWriting(state.writing, id)) { toast('Already writing a checkpoint for this session'); return }
|
|
3748
|
+
beginWriting(state.writing, id, { length: length || defaultLength(), lastCpId: (s && s.lastCheckpointId) || null, machine: away })
|
|
3749
|
+
const req = api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }).then((cp) => ({ cp }), (e) => ({ e }))
|
|
3750
|
+
await render()
|
|
3751
|
+
// nothing on screen shows it (⌘K from another page): say it once
|
|
3752
|
+
if (![...document.querySelectorAll('[data-writing]')].some((el) => el.dataset.writing === id)) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000)
|
|
3753
|
+
const { cp, e } = await req
|
|
3754
|
+
if (e && e.code === 'peer_slow') {
|
|
3755
|
+
settleWriting(state.writing, id, { slow: true })
|
|
3756
|
+
setTimeout(() => { if (state.writing[id] && !busy() && state.route && /^(board|s)$/.test(state.route.name || 'board')) render() }, WRITING_SYNC_MS + 300)
|
|
3757
|
+
return render()
|
|
3758
|
+
}
|
|
3759
|
+
if (e) { settleWriting(state.writing, id, { error: e.message }); toast(e.message, 6000); return render() }
|
|
3760
|
+
settleWriting(state.writing, id, { ok: true })
|
|
3761
|
+
state.cpFresh[cp.id] = Date.now()
|
|
3762
|
+
toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + plainCp(cp.next))
|
|
3641
3763
|
render()
|
|
3764
|
+
} else if (action === 'writing-dismiss') {
|
|
3765
|
+
dismissWriting(state.writing, btn.dataset.id || id); render()
|
|
3642
3766
|
} else if (action === 'cp-lengths') {
|
|
3643
3767
|
ev.preventDefault(); return openLengthMenu(btn, id)
|
|
3644
3768
|
} else if (action === 'peer-recheck') {
|
|
@@ -4014,33 +4138,27 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4014
4138
|
location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
|
|
4015
4139
|
} else if (ev.target.id === 'settingsForm') {
|
|
4016
4140
|
ev.preventDefault()
|
|
4141
|
+
// 0.9.5: nothing to save while the form matches what's saved (Save is off then too)
|
|
4142
|
+
if (saveUi.saving || !settingsChanged().length) return
|
|
4143
|
+
saveUi.saving = true; paintSave()
|
|
4017
4144
|
try {
|
|
4018
|
-
const
|
|
4019
|
-
const
|
|
4145
|
+
const v = settingsValues()
|
|
4146
|
+
const prov = v.provider
|
|
4020
4147
|
const prev = (state.cfg && state.cfg.summarizer) || {}
|
|
4021
4148
|
const changed = String(prev.provider || 'auto').toLowerCase() !== prov
|
|
4022
|
-
const summarizer = {
|
|
4023
|
-
provider: prov,
|
|
4024
|
-
model: (f.model && (((document.getElementById('f-model-other') || {}).value || '').trim() || document.getElementById('f-model').value.trim())) || 'haiku',
|
|
4025
|
-
baseUrl: (f.base && document.getElementById('f-base').value.trim()) || null,
|
|
4026
|
-
}
|
|
4149
|
+
const summarizer = { provider: prov, model: v.model, baseUrl: v.baseUrl }
|
|
4027
4150
|
// A key setting from another provider must not follow the switch.
|
|
4028
4151
|
if (changed) { summarizer.apiKeyEnv = null; summarizer.apiKeyFile = null }
|
|
4029
|
-
const key =
|
|
4030
|
-
if (key) await api('PUT', '/v1/config/keys/' +
|
|
4152
|
+
const key = v.key
|
|
4153
|
+
if (key) await api('PUT', '/v1/config/keys/' + (PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto).key, { key })
|
|
4031
4154
|
await api('PATCH', '/v1/config', {
|
|
4032
|
-
dormantAfterMin:
|
|
4033
|
-
minGapMin:
|
|
4034
|
-
digest:
|
|
4035
|
-
time: document.getElementById('f-time').value || null,
|
|
4036
|
-
days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
|
|
4037
|
-
skipEmpty: document.getElementById('f-skip').checked,
|
|
4038
|
-
timeZone: document.getElementById('f-tz').value || null,
|
|
4039
|
-
},
|
|
4155
|
+
dormantAfterMin: v.dormantAfterMin,
|
|
4156
|
+
minGapMin: v.minGapMin,
|
|
4157
|
+
digest: v.digest,
|
|
4040
4158
|
summarizer,
|
|
4041
|
-
summary: { length:
|
|
4042
|
-
updates: { check:
|
|
4043
|
-
notify: { needsYou:
|
|
4159
|
+
summary: { length: v.length },
|
|
4160
|
+
updates: { check: v.updates },
|
|
4161
|
+
notify: { needsYou: v.notify },
|
|
4044
4162
|
...(() => {
|
|
4045
4163
|
const el = document.getElementById('f-remote-answers')
|
|
4046
4164
|
if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
|
|
@@ -4048,11 +4166,15 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4048
4166
|
return { remote: { allowAnswers: el.checked } }
|
|
4049
4167
|
})(),
|
|
4050
4168
|
})
|
|
4169
|
+
// Saved: what's on screen is now what's saved, so Save goes off until the next edit.
|
|
4170
|
+
const kf = document.getElementById('f-key'); if (kf) kf.value = ''
|
|
4171
|
+
saveUi.base = settingsValues()
|
|
4051
4172
|
toast(key ? 'Saved · key stored in .secret/' : 'Saved'); render(); refreshHealth()
|
|
4052
|
-
} catch (e) { toast(e.message) }
|
|
4173
|
+
} catch (e) { toast(e.message) } finally { saveUi.saving = false; paintSave() }
|
|
4053
4174
|
}
|
|
4054
4175
|
})
|
|
4055
4176
|
|
|
4177
|
+
|
|
4056
4178
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
4057
4179
|
let pending = null
|
|
4058
4180
|
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
|
package/ui/cp-text.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 0.9.5: a checkpoint's text can be markdown (a model's write-up kept as it
|
|
2
|
+
// came, see salvageCheckpointReply in mod/hooks/core.js). The session page
|
|
3
|
+
// renders it; cards, the digest and one-line previews show it as plain text,
|
|
4
|
+
// without the ### or ** marks. Shared by the board and the daemon.
|
|
5
|
+
|
|
6
|
+
// Does this read as markdown (headings, lists, bold, links, fences)?
|
|
7
|
+
export function looksMd(s) {
|
|
8
|
+
return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s+\S|\d{1,3}[.)]\s+\S|>\s|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\s]+\)/.test(String(s == null ? '' : s))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Markdown → one plain line: headings end with ":", list items start with "•",
|
|
12
|
+
// emphasis and link syntax go, `code` stays (the board shows it as code).
|
|
13
|
+
export function stripMd(s) {
|
|
14
|
+
const text = String(s == null ? '' : s).replace(/\r\n?/g, '\n')
|
|
15
|
+
const out = []
|
|
16
|
+
let fence = false
|
|
17
|
+
for (const raw of text.split('\n')) {
|
|
18
|
+
let line = raw.trim()
|
|
19
|
+
if (/^(```|~~~)/.test(line)) { fence = !fence; continue }
|
|
20
|
+
if (!line) continue
|
|
21
|
+
if (!fence) {
|
|
22
|
+
if (/^([-*_])(\s*\1){2,}$/.test(line)) continue // a rule
|
|
23
|
+
let m
|
|
24
|
+
if ((m = line.match(/^#{1,6}\s+(.*?)\s*#*$/))) { line = inline(m[1]); if (line && !/[.:!?…]$/.test(line)) line += ':'; out.push(line); continue }
|
|
25
|
+
line = line.replace(/^>\s?/, '')
|
|
26
|
+
if ((m = line.match(/^[-*+]\s+(.*)$/))) line = '• ' + m[1]
|
|
27
|
+
}
|
|
28
|
+
out.push(fence ? line : inline(line))
|
|
29
|
+
}
|
|
30
|
+
return out.join(' ').replace(/\s+/g, ' ').trim()
|
|
31
|
+
}
|
|
32
|
+
function inline(t) {
|
|
33
|
+
return t
|
|
34
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
35
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
36
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/__([^_]+)__/g, '$1')
|
|
37
|
+
.replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1$2').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1$2')
|
|
38
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
39
|
+
.trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// For one-line places: plain when it reads as markdown, as it was otherwise.
|
|
43
|
+
export const plainCp = (s) => (looksMd(s) ? stripMd(s) : String(s == null ? '' : s))
|
package/ui/handoffs.js
CHANGED
|
@@ -19,7 +19,7 @@ export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function todoAddForm(sessionId = '') {
|
|
22
|
-
return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
22
|
+
return '<form class="todo-add addform sess-add" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// Open to-dos grouped by session (your own, with no session, last).
|