@leftoff/cli 0.9.17-beta.6 → 0.9.17-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/leftoff.js +451 -446
- 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 +135 -6
- package/ui/app.js +131 -14
- package/ui/hosted-by.js +50 -0
- package/ui/live-view.js +1 -0
- package/ui/needs.js +66 -20
- package/ui/prs.js +53 -8
- package/ui/relay-client.js +96 -6
- package/ui/scroll-keep.js +247 -0
- package/ui/walk-pop.js +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.8",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/core.js
CHANGED
|
@@ -683,7 +683,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
683
683
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
684
684
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
685
685
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
686
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook']
|
|
686
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort']
|
|
687
687
|
|
|
688
688
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
689
689
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -1622,8 +1622,13 @@ export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages
|
|
|
1622
1622
|
// clip). The board and the daemon use ui/cp-text.js stripChannel; this copy is
|
|
1623
1623
|
// here because the mod ships on its own. A test holds the two to the same answers.
|
|
1624
1624
|
const CH_PAIR = /<channel\b[^>]*>([\s\S]*?)<\/channel\s*>/g
|
|
1625
|
+
// 2026-10-29: Claude Code's wrapper for a prompt our plugin submits between turns
|
|
1626
|
+
// ("The leftoff plugin sent a message:\n<words>\n\nThis is how Claude Code surfaces a prompt …").
|
|
1627
|
+
const PLUGIN_WRAP = /^\s*The leftoff plugin sent a message:\s*\n([\s\S]*?)(?:\n\s*\nThis is how Claude Code surfaces a prompt[\s\S]*)?$/i
|
|
1625
1628
|
export function stripChannel(s) {
|
|
1626
1629
|
let t = String(s == null ? '' : s)
|
|
1630
|
+
const pw = PLUGIN_WRAP.exec(t)
|
|
1631
|
+
if (pw) t = pw[1]
|
|
1627
1632
|
if (!/<\/?channel\b/i.test(t)) return t.trim()
|
|
1628
1633
|
for (let i = 0; i < 8 && CH_PAIR.test(t); i++) { CH_PAIR.lastIndex = 0; t = t.replace(CH_PAIR, '$1') }
|
|
1629
1634
|
CH_PAIR.lastIndex = 0
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.17-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.17-beta.8' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.8",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -4384,12 +4384,59 @@ html.lo-reordering, html.lo-reordering * { cursor: grabbing !important; user-sel
|
|
|
4384
4384
|
html:not(.live-full) .fs-side { display: none; }
|
|
4385
4385
|
.fs-side-in { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 10px 16px 16px; container: fsside / inline-size; }
|
|
4386
4386
|
.fs-side-in > * { max-width: 100%; }
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
.fs-head
|
|
4391
|
-
.fs-head
|
|
4392
|
-
.fs-head
|
|
4387
|
+
/* 2026-10-28 the panel's head: one row (› hide, the title, then ✎ and ⋯ in one cluster, all one size,
|
|
4388
|
+
centred on the title's FIRST line), the chips on one baseline under it, then the folder line.
|
|
4389
|
+
--fsh-b: the buttons' size (32px with a mouse, 44px on touch); --fsh-lh: one title line. */
|
|
4390
|
+
.fs-head { display: block; margin: 0 0 4px; padding: 0; --fsh-b: 32px; --fsh-lh: 22px; }
|
|
4391
|
+
@media (pointer: coarse) { .fs-head { --fsh-b: 44px; } }
|
|
4392
|
+
.fs-head-row { display: flex; align-items: flex-start; gap: 4px; margin: 2px 0 6px; min-width: 0; }
|
|
4393
|
+
.fs-head .s-title { flex: 1 1 auto; min-width: 0; margin: 0; display: block; }
|
|
4394
|
+
.fs-head h1 { position: relative; font-size: 17px; line-height: var(--fsh-lh); margin: calc((var(--fsh-b) - var(--fsh-lh)) / 2) 0 0; overflow-wrap: anywhere; }
|
|
4395
|
+
.fs-head .s-title-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
|
|
4396
|
+
.fs-head .s-title input { margin: calc((var(--fsh-b) - 34px) / 2) 0 0; font-size: 17px; width: 100%; box-sizing: border-box; }
|
|
4397
|
+
.fs-hacts { flex: none; display: flex; align-items: center; gap: 2px; height: var(--fsh-b); }
|
|
4398
|
+
.fs-hacts .more-wrap { flex: none; display: inline-flex; }
|
|
4399
|
+
.fs-head .fs-side-x, .fs-hacts .fs-hact, .fs-hacts .btn.icon-btn.more { flex: none; position: relative; display: inline-flex; align-items: center; justify-content: center; width: var(--fsh-b); height: var(--fsh-b); margin: 0; padding: 0; border: 0; border-radius: 8px; background: none; box-shadow: none; color: var(--text2); cursor: pointer; }
|
|
4400
|
+
.fs-head .fs-side-x .ic, .fs-hacts .fs-hact svg, .fs-hacts .btn.icon-btn.more .ic { width: 16px; height: 16px; margin: 0; }
|
|
4401
|
+
.fs-head .fs-side-x { margin-left: -6px; }
|
|
4402
|
+
@media (hover: hover) { .fs-head .fs-side-x:hover, .fs-hacts .fs-hact:hover, .fs-hacts .btn.icon-btn.more:hover { background: var(--card2); color: var(--text); border-color: transparent; } }
|
|
4403
|
+
.fs-hacts .btn.icon-btn.more[aria-expanded="true"] { background: var(--card2); color: var(--text); }
|
|
4404
|
+
.fs-hacts .fs-hact:focus-visible, .fs-hacts .btn.icon-btn.more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
4405
|
+
.fs-hacts .retitle.is-busy { color: var(--accent); cursor: progress; }
|
|
4406
|
+
/* their tips hang below, right-aligned (the panel's edge is near) */
|
|
4407
|
+
.fs-side .fs-hacts [data-tip]::after { right: 0; left: auto; top: calc(100% + 4px); transform: none; }
|
|
4408
|
+
@media (hover: hover) and (pointer: fine) { .fs-hacts .fs-hact[data-tip]:hover::after, .fs-hacts .btn.more[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
4409
|
+
.fs-hacts .fs-hact[data-tip]:focus-visible::after, .fs-hacts .btn.more[data-tip]:focus-visible::after { display: block; }
|
|
4410
|
+
.fs-hacts .btn.more[aria-expanded="true"]::after { display: none !important; }
|
|
4411
|
+
/* the session's PRs: none found says why in one quiet line; Link a PR… focuses the box under it */
|
|
4412
|
+
.sprs .pr-none { position: relative; margin: 0 0 8px; font-size: 13px; line-height: 1.4; color: var(--muted); }
|
|
4413
|
+
.sprs .pr-none-link { cursor: pointer; color: var(--accent); }
|
|
4414
|
+
@media (hover: hover) { .sprs .pr-none-link:hover { text-decoration: underline; } }
|
|
4415
|
+
.fs-side .sprs .pr-none[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; max-width: 100%; }
|
|
4416
|
+
@media (hover: hover) and (pointer: fine) { .sprs .pr-none[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .6s both; } }
|
|
4417
|
+
/* 2026-10-29 state by shape plus the word; a ref that couldn't be checked is a quiet dashed chip, why in its tip */
|
|
4418
|
+
.pr-state { display: inline-flex; align-items: center; gap: 3px; line-height: 1; }
|
|
4419
|
+
.pr-st-ic { flex: none; width: 13px; height: 13px; position: relative; top: .5px; }
|
|
4420
|
+
.sprs .pr-unlist { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }
|
|
4421
|
+
.sprs .pr-unchecked { position: relative; display: inline-flex; align-items: center; gap: 5px; height: 26px; line-height: 1; padding: 0 9px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); background: transparent; max-width: 100%; min-width: 0; cursor: default; }
|
|
4422
|
+
.sprs .pr-unchecked .pr-un-ref { font-family: var(--mono); font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; position: relative; top: .5px; }
|
|
4423
|
+
.sprs .pr-unchecked .pr-un-word { flex: none; position: relative; top: .5px; }
|
|
4424
|
+
.sprs .pr-unchecked .pr-st-ic { color: var(--muted); }
|
|
4425
|
+
.sprs .pr-unchecked:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4426
|
+
@media (pointer: coarse) { .sprs .pr-unchecked { height: 32px; } .sprs .pr-unlist { gap: 8px; } }
|
|
4427
|
+
.fs-side .sprs .pr-unchecked[data-tip]::after, .sprs .pr-unchecked[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; width: 260px; max-width: 70vw; font-family: var(--sans, inherit); font-weight: 400; line-height: 1.35; z-index: 30; }
|
|
4428
|
+
@media (hover: hover) and (pointer: fine) { .sprs .pr-unchecked[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
4429
|
+
.sprs .pr-unchecked[data-tip]:focus-visible::after { display: block; }
|
|
4430
|
+
/* the whole title, when two lines cut it short */
|
|
4431
|
+
.fs-side .fs-head h1[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; width: max-content; max-width: 100%; font-size: 12px; line-height: 1.35; }
|
|
4432
|
+
@media (hover: hover) and (pointer: fine) { .fs-head h1[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .6s both; } }
|
|
4433
|
+
.fs-head .sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
|
4434
|
+
.fs-head .sub > .pill, .fs-head .sub > .chip, .fs-head .sub > .s-tsrc, .fs-head .sub > .head-sg { display: inline-flex; align-items: center; box-sizing: border-box; height: 22px; line-height: 1; margin: 0; padding-top: 1px; padding-bottom: 0; vertical-align: middle; }
|
|
4435
|
+
.fs-head .sub > .s-tsrc { margin-left: 2px; font-size: 12px; color: var(--muted); }
|
|
4436
|
+
.fs-repo { position: relative; }
|
|
4437
|
+
.fs-side .fs-repo[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
|
|
4438
|
+
@media (hover: hover) and (pointer: fine) { .fs-repo[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .5s both; } }
|
|
4439
|
+
@media (prefers-reduced-motion: reduce) { .fs-hacts [data-tip]::after, .fs-head h1[data-tip]::after, .fs-repo[data-tip]::after { animation: none !important; } }
|
|
4393
4440
|
.fs-where { display: flex; align-items: center; gap: 6px; margin: 2px 0; font-size: 13px; color: var(--text2); min-width: 0; }
|
|
4394
4441
|
.fs-where span:not(.faint) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4395
4442
|
.fs-where .ic { flex: none; width: 14px; height: 14px; opacity: .7; }
|
|
@@ -5003,3 +5050,85 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5003
5050
|
.nd-cur { flex: none; font-size: 12px; font-weight: 500; color: var(--muted); }
|
|
5004
5051
|
.nd-n { flex: none; display: inline-flex; align-items: center; justify-content: center; height: 20px; min-width: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 10px; background: var(--red); color: #fff; font-size: 11.5px; font-weight: 650; line-height: 1; }
|
|
5005
5052
|
.nd-item:focus-visible, .nd-x:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
5053
|
+
|
|
5054
|
+
/* 2026-10-29 Needs you on a phone (beta feedback: "statically positioned so it's hard to read the message
|
|
5055
|
+
before it"). In full screen it sits between the thread and the composer: at most 45% of the height you can
|
|
5056
|
+
see (the visual viewport, so the keyboard counts; 40vh measured the whole layout height on iOS), folds to a
|
|
5057
|
+
one-line bar, and on a phone it leaves the question and Claude's last lines to the thread right above it. */
|
|
5058
|
+
.live-box.full .lf-needs { max-height: min(40vh, calc(var(--lf-h, 100dvh) * .45)); overscroll-behavior: contain; }
|
|
5059
|
+
.needs-strip .needs-fold, .needs-strip .needs-peek { display: none; }
|
|
5060
|
+
.lf-needs .needs-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
5061
|
+
.lf-needs .needs-head h2 { flex: none; }
|
|
5062
|
+
.lf-needs .needs-strip .needs-fold { display: inline-flex; align-items: center; justify-content: center; flex: none; margin-left: auto; width: 44px; height: 44px; margin-top: -10px; margin-bottom: -10px; margin-right: -8px; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text2); cursor: pointer; }
|
|
5063
|
+
.lf-needs .needs-fold .ic { width: 18px; height: 18px; }
|
|
5064
|
+
@media (hover: hover) and (pointer: fine) { .lf-needs .needs-strip .needs-fold { width: 32px; height: 32px; margin-top: -6px; margin-bottom: -6px; margin-right: -4px; } .lf-needs .needs-fold:hover { background: var(--subtle); color: var(--text); } }
|
|
5065
|
+
.lf-needs .needs-fold:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
5066
|
+
.lf-needs .needs-strip.folded .needs-fold .ic { transform: rotate(180deg); }
|
|
5067
|
+
.lf-needs .needs-strip.folded .needs-list { display: none; }
|
|
5068
|
+
.lf-needs .needs-strip.folded .needs-peek { display: block; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); font-size: 13.5px; }
|
|
5069
|
+
.lf-needs .needs-strip.folded { padding-top: 8px; padding-bottom: 8px; }
|
|
5070
|
+
@media (max-width: 600px) {
|
|
5071
|
+
.live-box.full .lf-needs .needs-strip { margin: 6px 0 0; padding: 8px 10px 10px; }
|
|
5072
|
+
.live-box.full .lf-needs .needs-strip.folded { padding-top: 6px; padding-bottom: 6px; }
|
|
5073
|
+
.live-box.full .lf-needs .need-question, .live-box.full .lf-needs .need-ctx, .live-box.full .lf-needs .need-head { display: none; } /* the thread right above shows them */
|
|
5074
|
+
.live-box.full .lf-needs .need { padding: 0; border: 0; background: none; box-shadow: none; }
|
|
5075
|
+
.live-box.full .lf-needs .quick-replies { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); padding-right: 24px; } /* one row that scrolls; the fade says there's more */
|
|
5076
|
+
.live-box.full .lf-needs .quick-replies .qr { flex: none; max-width: 80vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5077
|
+
.live-box.full .lf-needs .need-actions { display: none; } /* Go opens the Mac's terminal; the bar's >_ is the phone's way there */
|
|
5078
|
+
/* answering in Needs you with the keyboard up: one box, not two */
|
|
5079
|
+
html.lf-kb.ny-typing .live-box.full > .composer { display: none; } /* ny-typing: app.js, kept a beat after the box loses focus so a tap lands where it was aimed */
|
|
5080
|
+
}
|
|
5081
|
+
@media (prefers-reduced-motion: no-preference) { .lf-needs .needs-fold .ic { transition: transform .15s ease; } }
|
|
5082
|
+
|
|
5083
|
+
/* 2026-10-29 the composer's suggestion on a touch screen (iOS has no Tab key; a long one was cut off):
|
|
5084
|
+
a chip above the box. "↳ Use suggestion" + its words (2 lines; ⌄ or a long-press shows all), ✕ dismisses. */
|
|
5085
|
+
.sug-chip { display: flex; align-items: stretch; gap: 2px; margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); min-height: 44px; box-sizing: border-box; }
|
|
5086
|
+
.sug-chip button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
5087
|
+
.sug-chip button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
5088
|
+
.sug-use { flex: 1 1 auto; min-width: 0; min-height: 44px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 5px 4px 5px 12px; text-align: left; border-radius: 11px 0 0 11px; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
|
|
5089
|
+
.sug-l { font-size: 11.5px; line-height: 1.2; font-weight: 600; color: var(--accent); }
|
|
5090
|
+
.sug-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; font-size: 14px; line-height: 1.3; color: var(--text2); overflow-wrap: anywhere; }
|
|
5091
|
+
.sug-chip.open .sug-text { display: block; -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
|
|
5092
|
+
.sug-more, .sug-x { flex: none; width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; color: var(--text2); }
|
|
5093
|
+
.sug-x { border-radius: 0 11px 11px 0; }
|
|
5094
|
+
.sug-more[hidden] { display: none; }
|
|
5095
|
+
.sug-more .ic, .sug-x .ic { width: 18px; height: 18px; }
|
|
5096
|
+
.sug-chip.open .sug-more .ic { transform: rotate(180deg); }
|
|
5097
|
+
@media (hover: hover) and (pointer: fine) { .sug-use:hover, .sug-more:hover, .sug-x:hover { background: var(--subtle); } }
|
|
5098
|
+
@media (prefers-reduced-motion: no-preference) { .sug-more .ic { transition: transform .15s ease; } }
|
|
5099
|
+
/* desktop: the words cut short, the Tab key always shown; a long one gets ⋯ with all of it in its tip */
|
|
5100
|
+
.ghost { display: flex; align-items: flex-start; gap: 0; }
|
|
5101
|
+
.ghost .ghost-t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5102
|
+
.ghost .ghost-k { flex: none; }
|
|
5103
|
+
.ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
|
|
5104
|
+
.ghost .ghost-k { align-self: center; }
|
|
5105
|
+
|
|
5106
|
+
/* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
|
|
5107
|
+
.hostedby { min-width: 0; }
|
|
5108
|
+
.hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
5109
|
+
.host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
|
|
5110
|
+
.host-row + .host-row { border-top: 1px solid var(--line); }
|
|
5111
|
+
.host-row.is-host { background: var(--bg2); }
|
|
5112
|
+
.host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
|
|
5113
|
+
.host-row.on .host-shape { color: var(--text); }
|
|
5114
|
+
.host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
|
|
5115
|
+
.host-sub { flex-basis: 100%; font-size: 12.5px; }
|
|
5116
|
+
.host-chip { font-weight: 600; }
|
|
5117
|
+
.pref-chip { border-style: dashed; }
|
|
5118
|
+
.host-make { flex: none; min-height: 44px; }
|
|
5119
|
+
.host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
|
|
5120
|
+
.hostedby > .hint { margin-top: 8px; }
|
|
5121
|
+
.host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
|
|
5122
|
+
.hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
|
|
5123
|
+
.hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
|
|
5124
|
+
.hp-opt.on::before { content: '✓ '; }
|
|
5125
|
+
.hp-opt:disabled { opacity: .55; cursor: default; }
|
|
5126
|
+
.hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
5127
|
+
@media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
|
|
5128
|
+
.relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
|
|
5129
|
+
.relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
|
|
5130
|
+
.relay-host + .relay-host { border-top: 1px solid var(--line); }
|
|
5131
|
+
.relay-host-shape { width: 16px; text-align: center; flex: none; }
|
|
5132
|
+
.relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
|
|
5133
|
+
.relay-use { min-height: 44px; margin-left: auto; }
|
|
5134
|
+
@media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
|
package/ui/app.js
CHANGED
|
@@ -34,6 +34,8 @@ 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 { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
|
|
38
|
+
import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
|
|
37
39
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
38
40
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
39
41
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
@@ -275,6 +277,10 @@ document.addEventListener('touchend', (e) => {
|
|
|
275
277
|
applyNav()
|
|
276
278
|
|
|
277
279
|
const $app = document.getElementById('app')
|
|
280
|
+
// 0.9.18 (ui/scroll-keep.js): a redraw anywhere (a live update, a poll) puts every scroll box, the focus
|
|
281
|
+
// and an open <details> back where you had them; the app's own positioning (stick to bottom, a new page) wins
|
|
282
|
+
const scrollKeep = createScrollKeep()
|
|
283
|
+
scrollKeep.start()
|
|
278
284
|
// 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
|
|
279
285
|
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() }
|
|
280
286
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
@@ -2376,6 +2382,10 @@ async function viewSession(id) {
|
|
|
2376
2382
|
// 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
|
|
2377
2383
|
// each session, read before caughtUp folds its card; a new visit starts over.
|
|
2378
2384
|
const openAway = new Map()
|
|
2385
|
+
// 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
|
|
2386
|
+
// while you were away reads at least "day" depth (ui/away.js), but its label says
|
|
2387
|
+
// how long it has really gone unseen: never more than the session's own age.
|
|
2388
|
+
function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
|
|
2379
2389
|
function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
|
|
2380
2390
|
function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
|
|
2381
2391
|
function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
|
|
@@ -2523,16 +2533,25 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2523
2533
|
// 0.9.17 Walk me through it: in place of the chat (the bar and tabs stay); ‹ Chat goes back
|
|
2524
2534
|
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>'
|
|
2525
2535
|
// ---- the sidebar
|
|
2536
|
+
// 2026-10-28 one aligned head row: › hide, then the title (two lines at most, the whole
|
|
2537
|
+
// title in its tip), then the actions in one cluster on the first line: ✎ Rename and ⋯
|
|
2538
|
+
// (Regenerate title is the ⋯'s first row; the ↻ beside the chat's own title stays). While a new
|
|
2539
|
+
// title is being made, its spinner sits in the cluster.
|
|
2540
|
+
const fullTitle = sessionTitle(s)
|
|
2526
2541
|
const title = canRename
|
|
2527
|
-
? '<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 || '') + '"
|
|
2528
|
-
: '<div class="title s-title"><h1>' + titleHtml(sessionTitle(s), esc) + '</h1></div>'
|
|
2542
|
+
? '<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>'
|
|
2543
|
+
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
|
|
2529
2544
|
const menu = fsMenuItems(s, mine)
|
|
2545
|
+
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2530
2546
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
2547
|
+
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
|
|
2531
2548
|
const where = [s.repoLabel || s.repoName || (s.repo && s.repo.name) || '', branch || ''].filter(Boolean)
|
|
2532
|
-
const
|
|
2533
|
-
|
|
2549
|
+
const wherePath = (s.repo && (s.repo.worktree || s.repo.root)) || s.cwd || ''
|
|
2550
|
+
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2551
|
+
'<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>' +
|
|
2552
|
+
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2534
2553
|
'<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>' +
|
|
2535
|
-
(where.length ? '<div class="fs-where">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2554
|
+
(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>' : '') +
|
|
2536
2555
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>' + '</div>'
|
|
2537
2556
|
// the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
|
|
2538
2557
|
noteOpenAway(s)
|
|
@@ -2541,7 +2560,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2541
2560
|
const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
|
|
2542
2561
|
// 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
|
|
2543
2562
|
// else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
|
|
2544
|
-
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2563
|
+
const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
|
|
2545
2564
|
const wtOn = wtUi.sid === s.id
|
|
2546
2565
|
const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
|
|
2547
2566
|
// 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
|
|
@@ -2583,7 +2602,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2583
2602
|
side += answeredList(s)
|
|
2584
2603
|
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 ⋯
|
|
2585
2604
|
const label = fsPeekWords(s)
|
|
2586
|
-
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 })
|
|
2605
|
+
return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, 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 })
|
|
2587
2606
|
}
|
|
2588
2607
|
// The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
|
|
2589
2608
|
// "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
|
|
@@ -2608,6 +2627,8 @@ function fsMenuItems(s, mine) {
|
|
|
2608
2627
|
if (reachOf(s) === 'away') return awayMenuItems(s)
|
|
2609
2628
|
const pinned = !!s.pinned
|
|
2610
2629
|
return [
|
|
2630
|
+
// 2026-10-28: Regenerate title is the ⋯'s first row (out of the head row, so ✎ and ⋯ line up; the chat's own ↻ stays)
|
|
2631
|
+
mine && !ownerOffline(s) ? { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', attrs: { 'data-id': s.id, 'data-tip': RETITLE_TIP } } : null,
|
|
2611
2632
|
{ action: 'fs-timeline', label: 'Open in Timeline', icon: 'history', attrs: { 'data-id': s.id } },
|
|
2612
2633
|
{ action: 'resume', label: 'Copy resume command', icon: 'copy', attrs: { 'data-id': s.id } },
|
|
2613
2634
|
s.liveResumeCommand ? { action: 'copy-cmd', label: 'Copy Live resume', icon: 'copy', attrs: { 'data-cmd': s.liveResumeCommand } } : null,
|
|
@@ -2629,17 +2650,63 @@ function paintGhost(sid) {
|
|
|
2629
2650
|
const s = state.sessions.find((x) => x.id === sid)
|
|
2630
2651
|
const g = ghostUi.get(sid)
|
|
2631
2652
|
const show = ghostShows(g, { value: ta.value, idle: ghostIdle(s) }) && !ta.disabled
|
|
2653
|
+
// 2026-10-29 (beta feedback: iOS has no Tab key, and a long suggestion was cut off): on a touch
|
|
2654
|
+
// screen it is a chip above the box instead of grey text in it (paintSugChip)
|
|
2655
|
+
const touch = isTouchUi(window.matchMedia)
|
|
2656
|
+
paintSugChip(box, sid, show && touch ? g : null)
|
|
2632
2657
|
let el = box.querySelector('.ghost')
|
|
2633
|
-
if (!show) { if (el) el.hidden = true; box.classList.remove('has-ghost'); return }
|
|
2658
|
+
if (!show || touch) { if (el) el.hidden = true; box.classList.remove('has-ghost'); if (!show) ta.removeAttribute('aria-description'); return }
|
|
2634
2659
|
if (!el) { el = document.createElement('div'); el.className = 'ghost'; el.setAttribute('aria-hidden', 'true'); box.appendChild(el) }
|
|
2635
2660
|
const cs = getComputedStyle(ta)
|
|
2636
2661
|
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' })
|
|
2637
|
-
el.textContent =
|
|
2662
|
+
el.textContent = ''
|
|
2663
|
+
// 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
|
|
2664
|
+
const t = document.createElement('span'); t.className = 'ghost-t'; t.textContent = g.text; el.appendChild(t)
|
|
2638
2665
|
const k = document.createElement('span'); k.className = 'ghost-k'; k.textContent = 'Tab'; el.appendChild(k)
|
|
2639
2666
|
el.hidden = false
|
|
2640
2667
|
box.classList.add('has-ghost')
|
|
2668
|
+
if (t.scrollWidth > t.clientWidth + 1) {
|
|
2669
|
+
const all = document.createElement('span'); all.className = 'ghost-all'; all.textContent = '⋯'; all.dataset.tip = g.text
|
|
2670
|
+
el.insertBefore(all, k)
|
|
2671
|
+
}
|
|
2641
2672
|
ta.setAttribute('aria-description', 'Suggested: ' + g.text + '. Press Tab to use it.')
|
|
2642
2673
|
}
|
|
2674
|
+
// The suggestion on a touch screen: a row above the composer. "↳ Use suggestion" and its words
|
|
2675
|
+
// (two lines; ⌄ or a long-press shows all of it) put it in the box to edit; ✕ dismisses it.
|
|
2676
|
+
// 44px targets. Never sent on its own.
|
|
2677
|
+
function paintSugChip(box, sid, g) {
|
|
2678
|
+
const host = box.parentNode
|
|
2679
|
+
let chip = host && host.querySelector(':scope > .sug-chip[data-sid="' + CSS.escape(sid) + '"]')
|
|
2680
|
+
if (!g) { if (chip) chip.remove(); return }
|
|
2681
|
+
if (chip && chip.dataset.text === g.text) return
|
|
2682
|
+
if (chip) chip.remove()
|
|
2683
|
+
chip = document.createElement('div')
|
|
2684
|
+
chip.className = 'sug-chip'; chip.dataset.sid = sid; chip.dataset.text = g.text
|
|
2685
|
+
chip.setAttribute('role', 'group'); chip.setAttribute('aria-label', 'Suggested message')
|
|
2686
|
+
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>' +
|
|
2687
|
+
'<button type="button" class="sug-more" aria-expanded="false" aria-label="Show all of the suggestion" data-tip="Show all">' + icon('chevron-down') + '</button>' +
|
|
2688
|
+
'<button type="button" class="sug-x" aria-label="Dismiss suggestion" data-tip="Dismiss">' + icon('x') + '</button>'
|
|
2689
|
+
chip.querySelector('.sug-text').textContent = g.text
|
|
2690
|
+
host.insertBefore(chip, box)
|
|
2691
|
+
const text = chip.querySelector('.sug-text'), more = chip.querySelector('.sug-more'), use = chip.querySelector('.sug-use')
|
|
2692
|
+
more.hidden = text.scrollHeight <= text.clientHeight + 1
|
|
2693
|
+
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' }
|
|
2694
|
+
more.addEventListener('click', () => expand(!chip.classList.contains('open')))
|
|
2695
|
+
let press = 0, long = false
|
|
2696
|
+
use.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; long = false; clearTimeout(press); press = setTimeout(() => { long = true; if (!more.hidden) expand(true) }, 500) })
|
|
2697
|
+
for (const t of ['pointerup', 'pointercancel', 'pointerleave']) use.addEventListener(t, () => clearTimeout(press))
|
|
2698
|
+
use.addEventListener('contextmenu', (e) => e.preventDefault())
|
|
2699
|
+
use.addEventListener('click', (e) => {
|
|
2700
|
+
if (long) { long = false; e.preventDefault(); return } // a long-press shows it, it doesn't use it
|
|
2701
|
+
const ta = box.querySelector('textarea.composer-input'), cur = ghostUi.get(sid)
|
|
2702
|
+
if (!ta || !cur) return
|
|
2703
|
+
ta.value = cur.text
|
|
2704
|
+
cur.cleared = true
|
|
2705
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft is kept, the box fits, the ↑ lights
|
|
2706
|
+
paintGhost(sid)
|
|
2707
|
+
})
|
|
2708
|
+
chip.querySelector('.sug-x').addEventListener('click', () => { const cur = ghostUi.get(sid); if (cur) cur.cleared = true; paintGhost(sid) })
|
|
2709
|
+
}
|
|
2643
2710
|
async function loadGhost(s, v) {
|
|
2644
2711
|
if (!s || s.agent || !isFullLive(s.id) || state.mayAnswer === false) return
|
|
2645
2712
|
const last = lastReply(v && !v.error ? v.messages : null)
|
|
@@ -2698,7 +2765,40 @@ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
|
|
|
2698
2765
|
function sessionNeedsStrip(s) {
|
|
2699
2766
|
// 0.9.14: an asleep Mac's ask isn't live: what it was, muted, and no Allow / Deny
|
|
2700
2767
|
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>'
|
|
2701
|
-
|
|
2768
|
+
// 2026-10-29 (beta feedback: on a phone the block covered the message you're answering): it folds to a one-line bar
|
|
2769
|
+
// (the question's words) and back; the choice is kept per session through repaints (needsFold)
|
|
2770
|
+
const folded = needsFold.has(s.id)
|
|
2771
|
+
const peek = needsLine(s.needsYou) || ''
|
|
2772
|
+
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>' : '') +
|
|
2773
|
+
'<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>' }
|
|
2774
|
+
const needsFold = new Set() // session ids whose Needs-you block is folded to its bar (this page only)
|
|
2775
|
+
// Typing a reply in full screen's Needs you with the keyboard up: the Live composer under it steps aside (one box,
|
|
2776
|
+
// 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.
|
|
2777
|
+
{
|
|
2778
|
+
let t = 0
|
|
2779
|
+
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') } })
|
|
2780
|
+
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) } })
|
|
2781
|
+
}
|
|
2782
|
+
function needsFoldClick(btn) {
|
|
2783
|
+
const sid = btn.dataset.id
|
|
2784
|
+
const on = !needsFold.has(sid)
|
|
2785
|
+
if (on) needsFold.add(sid); else needsFold.delete(sid)
|
|
2786
|
+
const strip = btn.closest('.needs-strip')
|
|
2787
|
+
if (strip) strip.classList.toggle('folded', on)
|
|
2788
|
+
btn.setAttribute('aria-expanded', String(!on))
|
|
2789
|
+
btn.setAttribute('aria-label', on ? 'Show Needs you' : 'Fold Needs you to a bar')
|
|
2790
|
+
btn.dataset.tip = on ? 'Show' : 'Fold to a bar'
|
|
2791
|
+
const peek = strip && strip.querySelector('.needs-peek'); if (peek) peek.setAttribute('aria-hidden', String(!on))
|
|
2792
|
+
}
|
|
2793
|
+
// 2026-10-29: the full-screen thread keeps its end in view when the room under it changes (the keyboard,
|
|
2794
|
+
// Needs you folding or growing): a thread that was at its bottom stays there; one you scrolled up is left alone.
|
|
2795
|
+
function pinLogEnd(ol) {
|
|
2796
|
+
if (!ol || ol._pinEnd || typeof ResizeObserver !== 'function') return
|
|
2797
|
+
const atEnd = () => ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24
|
|
2798
|
+
ol._pinEnd = { at: atEnd() }
|
|
2799
|
+
ol.addEventListener('scroll', () => { ol._pinEnd.at = atEnd() }, { passive: true })
|
|
2800
|
+
new ResizeObserver(() => { if (ol._pinEnd.at && ol.isConnected) ol.scrollTop = ol.scrollHeight }).observe(ol)
|
|
2801
|
+
}
|
|
2702
2802
|
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
2703
2803
|
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
2704
2804
|
const liveKeeper = createLiveKeeper()
|
|
@@ -2964,6 +3064,7 @@ function fullAfterRender(sid) {
|
|
|
2964
3064
|
paintLfTabs()
|
|
2965
3065
|
const ol = box.querySelector('.live-log')
|
|
2966
3066
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
3067
|
+
pinLogEnd(ol)
|
|
2967
3068
|
} else if (fullUi.back === sid) {
|
|
2968
3069
|
fullUi.back = null
|
|
2969
3070
|
const b = document.getElementById('live')
|
|
@@ -3560,6 +3661,7 @@ async function refreshLive(reason) {
|
|
|
3560
3661
|
const r = liveKeeper.after(box)
|
|
3561
3662
|
hydrateAttThumbs()
|
|
3562
3663
|
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
3664
|
+
if (box.classList.contains('full')) pinLogEnd(fresh.querySelector('.live-log'))
|
|
3563
3665
|
}
|
|
3564
3666
|
}
|
|
3565
3667
|
const c = sessionCache.get(id); if (c) c.live = v
|
|
@@ -4889,6 +4991,8 @@ function anywhereSection(r, adv = []) {
|
|
|
4889
4991
|
if (r.enabled) {
|
|
4890
4992
|
const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
|
|
4891
4993
|
h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
|
|
4994
|
+
// 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
|
|
4995
|
+
h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
|
|
4892
4996
|
if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
|
|
4893
4997
|
// Pairing from a paired phone follows Trust my paired devices (0.6.6).
|
|
4894
4998
|
const mayPair = v.mayPair != null ? !!v.mayPair : local
|
|
@@ -7366,13 +7470,13 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7366
7470
|
tip.classList.add('on')
|
|
7367
7471
|
}, delay)
|
|
7368
7472
|
}
|
|
7369
|
-
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
|
|
7473
|
+
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
|
|
7370
7474
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
7371
7475
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
7372
7476
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
7373
7477
|
document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
|
|
7374
7478
|
for (const ev of ['pointerdown', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
|
|
7375
|
-
window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
|
|
7479
|
+
window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil && !scrollKeep.quiet()) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
|
|
7376
7480
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
|
|
7377
7481
|
// 0.9.18 touch: a long press shows the tip (the tap it ends with doesn't follow the link)
|
|
7378
7482
|
let lp = 0, lpd = false
|
|
@@ -7390,7 +7494,7 @@ function wtStuck(sc) {
|
|
|
7390
7494
|
}
|
|
7391
7495
|
document.addEventListener('scroll', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('wt-scroll')) wtStuck(t) }, true)
|
|
7392
7496
|
// 0.9.18 Walk me through it and Catch me up explain themselves (ui/walk-pop.js): hover, focus, long-press, the (i)
|
|
7393
|
-
loPop = initPops({ esc, icon, contentFor: (kind, sid, el) => popContent(kind, { size: kind === 'wt' ? (wtSize.get(sid) || {}).data || null : null, away: !!(el && el.dataset.away) }) })
|
|
7497
|
+
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) }) })
|
|
7394
7498
|
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
7395
7499
|
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
7396
7500
|
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
@@ -8168,6 +8272,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
8168
8272
|
refreshHealth().catch(() => {})
|
|
8169
8273
|
return render()
|
|
8170
8274
|
}
|
|
8275
|
+
// 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
|
|
8276
|
+
if (action === 'relay-host' || action === 'relay-host-pref') {
|
|
8277
|
+
const machine = btn.dataset.machine || null
|
|
8278
|
+
const prefer = action === 'relay-host-pref'
|
|
8279
|
+
btn.disabled = true
|
|
8280
|
+
let hz
|
|
8281
|
+
try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
8282
|
+
const now = hz && hz.host && hz.host.machine
|
|
8283
|
+
toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
|
|
8284
|
+
if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
|
|
8285
|
+
return render()
|
|
8286
|
+
}
|
|
8171
8287
|
if (action === 'relay-user') {
|
|
8172
8288
|
const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
|
|
8173
8289
|
await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
|
|
@@ -8647,6 +8763,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8647
8763
|
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
8648
8764
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
8649
8765
|
if (action === 'need-more') return needMoreClick(btn)
|
|
8766
|
+
if (action === 'needs-fold') return needsFoldClick(btn)
|
|
8650
8767
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
8651
8768
|
// ---- To do and Files (0.7.0)
|
|
8652
8769
|
if (action === 'todo-restore' || action === 'todo-restore-all') {
|
|
@@ -8749,7 +8866,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8749
8866
|
if (action === 'title-auto') toast('Naming it from the latest messages…', 20000)
|
|
8750
8867
|
await runRetitle(id, { api, busy: retitling, render, toast })
|
|
8751
8868
|
} else if (action === 'rename') {
|
|
8752
|
-
const box = btn.closest('.title')
|
|
8869
|
+
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
|
|
8753
8870
|
if (!box || box.querySelector('input')) return
|
|
8754
8871
|
const current = s ? s.title || '' : btn.dataset.title || ''
|
|
8755
8872
|
box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">' +
|
package/ui/hosted-by.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 0.9.18 Hosted by (Settings → Use from anywhere): which of your machines serves
|
|
2
|
+
// <user>.leftoff.dev. The relay decides (relay/src/hosting.js): your preferred host
|
|
3
|
+
// whenever it's online, else the one already hosting, else an online one (Macs
|
|
4
|
+
// first). Here: each machine with its state by shape and word, the host marked,
|
|
5
|
+
// "Make host" per online machine, and Preferred (Automatic or a machine).
|
|
6
|
+
// Pure (no DOM), so the check and the unit tests render it directly.
|
|
7
|
+
const STATE = {
|
|
8
|
+
online: ['●', 'Online'],
|
|
9
|
+
not_answering: ['◐', 'Not answering'],
|
|
10
|
+
reconnecting: ['◌', 'Reconnecting'],
|
|
11
|
+
asleep: ['○', 'Offline'],
|
|
12
|
+
}
|
|
13
|
+
export function hostState(m) { return STATE[m && m.state] || (m && m.online ? STATE.online : STATE.asleep) }
|
|
14
|
+
const kind = (p) => (p === 'darwin' ? 'Mac' : p === 'linux' ? 'Linux' : p ? p : '')
|
|
15
|
+
|
|
16
|
+
export function hostedByHtml(h, { esc = (s) => String(s == null ? '' : s), canChange = true, viewing = null } = {}) {
|
|
17
|
+
if (!h || !Array.isArray(h.macs) || !h.macs.length) return ''
|
|
18
|
+
let out = '<label>Hosted by</label><div class="hostedby" id="hostedBy">'
|
|
19
|
+
if (!h.supported) {
|
|
20
|
+
out += '<div class="hint nomt">Your relay serves the board from the machine that connected last. Choosing a host needs a relay update.</div></div>'
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
const host = h.host || {}
|
|
24
|
+
const pref = host.preferred || null
|
|
25
|
+
out += '<ul class="hosts" role="list">'
|
|
26
|
+
for (const m of h.macs) {
|
|
27
|
+
const [shape, word] = hostState(m)
|
|
28
|
+
const isHost = m.machine === host.machine
|
|
29
|
+
const k = kind(m.platform)
|
|
30
|
+
out += '<li class="host-row' + (isHost ? ' is-host' : '') + (m.online ? ' on' : ' off') + '" data-machine="' + esc(m.machine) + '">' +
|
|
31
|
+
'<span class="host-shape" aria-hidden="true">' + shape + '</span>' +
|
|
32
|
+
'<span class="host-main"><b>' + esc(m.machine) + '</b>' + (isHost ? ' <span class="chip host-chip">Host</span>' : '') + (m.machine === pref ? ' <span class="chip pref-chip">Preferred</span>' : '') +
|
|
33
|
+
'<span class="faint host-sub">' + word + (k ? ' · ' + esc(k) : '') + (viewing && viewing === m.machine && !isHost ? ' · this browser' : '') + (m.serves === 0 ? ' · can’t open your paired browsers’ keys' : '') + '</span></span>'
|
|
34
|
+
if (canChange && !isHost) {
|
|
35
|
+
out += '<button type="button" class="btn sm host-make" data-action="relay-host" data-machine="' + esc(m.machine) + '"' + (m.online ? '' : ' disabled data-tip="' + esc(m.machine) + ' is offline: it can host once it’s online"') + '>Make host</button>'
|
|
36
|
+
}
|
|
37
|
+
out += '</li>'
|
|
38
|
+
}
|
|
39
|
+
out += '</ul>'
|
|
40
|
+
if (host.pending) out += '<div class="hint nomt host-pending">' + esc(host.pending.machine) + ' is back and takes over in a few seconds.</div>'
|
|
41
|
+
if (!host.machine) out += '<div class="hint nomt warnish">None of your machines is online. The first one to connect hosts.</div>'
|
|
42
|
+
// Preferred: a segmented choice, Automatic first
|
|
43
|
+
out += '<div class="host-pref" role="radiogroup" aria-label="Preferred host"><span class="host-pref-label">Preferred</span>'
|
|
44
|
+
const opt = (val, label, tip) => '<button type="button" role="radio" class="hp-opt' + ((pref || '') === val ? ' on' : '') + '" aria-checked="' + ((pref || '') === val ? 'true' : 'false') + '" data-action="relay-host-pref" data-machine="' + esc(val) + '" data-tip="' + esc(tip) + '"' + (canChange ? '' : ' disabled') + '>' + esc(label) + '</button>'
|
|
45
|
+
out += opt('', 'Automatic', 'Any online machine; a Mac before Linux, and the host stays put while it’s online')
|
|
46
|
+
for (const m of h.macs) out += opt(m.machine, m.machine, m.machine + ' hosts whenever it’s online; when it’s closed another machine takes over, and it takes back when it returns')
|
|
47
|
+
out += '</div>'
|
|
48
|
+
out += '<div class="hint">' + (pref ? esc(pref) + ' hosts whenever it’s online. ' : '') + 'When the host closes or loses its network, another online machine takes over within about half a minute. Phones and browsers stay paired.</div>'
|
|
49
|
+
return out + '</div>'
|
|
50
|
+
}
|
package/ui/live-view.js
CHANGED
|
@@ -161,6 +161,7 @@ export function senderLabel(m) {
|
|
|
161
161
|
if (m.from === 'claude') return m.agentName || 'Claude' // 0.9.17: an ended Codex session's answers say Codex
|
|
162
162
|
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
163
163
|
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
164
|
+
if (m.origin === 'needs') return 'You · from Needs you' // 2026-10-29: a reply sent from a Needs-you card (its own words, not the transcript's form)
|
|
164
165
|
if (m.history && !m.by) return 'You · board'
|
|
165
166
|
const by = m.by || {}
|
|
166
167
|
let device = by.device || null
|