@leftoff/cli 0.9.8 → 0.9.9-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/bin/leftoff.js +365 -358
- package/docs/SECURITY.md +12 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +9 -1
- package/mod/hooks/register.js +21 -8
- package/package.json +1 -1
- package/ui/app.css +54 -5
- package/ui/app.js +125 -29
- package/ui/composer.js +39 -1
- package/ui/icons.js +1 -0
- package/ui/needs.js +16 -11
- package/ui/start.js +136 -0
package/ui/composer.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// something to send. On a Mac (a fine pointer) Enter sends and Shift+Enter is a
|
|
5
5
|
// new line; on a phone or tablet Return is a new line and the button sends.
|
|
6
6
|
// ⌘↵ / Ctrl+↵ sends everywhere. Pure functions here (shared with the unit
|
|
7
|
-
// tests); app.js wires them to the page.
|
|
7
|
+
// tests); app.js wires them to the page. composerBoxHtml() is the box's markup,
|
|
8
|
+
// shared by Live and the Needs-you Reply card (ui/needs.js).
|
|
8
9
|
|
|
9
10
|
export const COMPOSER_MAX_LINES = 8
|
|
10
11
|
|
|
@@ -44,3 +45,40 @@ export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, ma
|
|
|
44
45
|
const want = Math.ceil(Number(scrollHeight) || 0)
|
|
45
46
|
return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
|
|
46
47
|
}
|
|
48
|
+
|
|
49
|
+
// The box's markup (Live and the Needs-you Reply card: one look, one
|
|
50
|
+
// behaviour). Pure: the caller hands in what it knows.
|
|
51
|
+
// submit: the data-submit / data-submit-for pair ('live' | 'reply')
|
|
52
|
+
// sendAction: the ↑'s data-action ('live-send' | 'reply-send')
|
|
53
|
+
// draftKey, draft: the textarea's data-draft key and its text
|
|
54
|
+
// may: answering from this device is allowed (false: everything disabled)
|
|
55
|
+
// busy: a send is in flight (the ↑ stays disabled)
|
|
56
|
+
// placeholder, label: the textarea's placeholder and aria-label
|
|
57
|
+
// inputClass: extra class(es) on the textarea (the app's own hooks)
|
|
58
|
+
// sid: the session; on the box as data-sid only when attach is on (the
|
|
59
|
+
// attachment code finds a session's composer by it)
|
|
60
|
+
// attach: show the 📎 and the hidden file input; atts: its chips ([]),
|
|
61
|
+
// chipHtml: chip -> markup
|
|
62
|
+
// keys: the textarea's aria-keyshortcuts
|
|
63
|
+
// Tooltips are the board's styled data-tip (never title=); aria-label stays.
|
|
64
|
+
export const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
|
|
65
|
+
export const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
|
|
66
|
+
const escA = (v) => String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
67
|
+
export function composerBoxHtml(o = {}) {
|
|
68
|
+
const may = o.may !== false
|
|
69
|
+
const atts = o.attach && Array.isArray(o.atts) ? o.atts : []
|
|
70
|
+
const sid = o.sid == null ? '' : String(o.sid)
|
|
71
|
+
const off = !may || o.busy || (isBlank(o.draft) && !atts.length)
|
|
72
|
+
const submit = escA(o.submit || 'live')
|
|
73
|
+
const label = o.label || o.placeholder || 'Message'
|
|
74
|
+
const dis = may ? '' : ' disabled'
|
|
75
|
+
return '<div class="composer' + (o.boxClass ? ' ' + escA(o.boxClass) : '') + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '"' + (o.attach && sid ? ' data-sid="' + escA(sid) + '"' : '') + '>' +
|
|
76
|
+
(o.attach ? '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(o.chipHtml || (() => '')).join('') + '</div>' : '') +
|
|
77
|
+
'<textarea class="composer-input' + (o.inputClass ? ' ' + escA(o.inputClass) : '') + '" rows="1" maxlength="' + (Number(o.maxlength) || 4000) + '" placeholder="' + escA(o.placeholder || '') + '" aria-label="' + escA(label) + '" autocomplete="off" data-draft="' + escA(o.draftKey || '') + '" data-submit="' + submit + '"' + (o.keys ? ' aria-keyshortcuts="' + escA(o.keys) + '"' : '') + dis + '>' + escA(o.draft || '') + '</textarea>' +
|
|
78
|
+
(o.attach
|
|
79
|
+
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
80
|
+
? '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + escA(sid) + '"' + dis + '>' +
|
|
81
|
+
'<button type="button" class="composer-attach" data-action="attach" data-id="' + escA(sid) + '" aria-label="Attach" data-tip="Attach photos or files"' + dis + '><span class="composer-clip">' + CLIP_IC + '</span></button>'
|
|
82
|
+
: '') +
|
|
83
|
+
'<button type="button" class="composer-send" data-action="' + escA(o.sendAction || 'live-send') + '" data-submit-for="' + submit + '" data-id="' + escA(sid) + '" aria-label="Send" data-tip="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
|
|
84
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -46,6 +46,7 @@ export const ICONS = {
|
|
|
46
46
|
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
47
47
|
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
48
48
|
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
|
|
49
|
+
"square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
|
|
49
50
|
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
|
|
50
51
|
}
|
|
51
52
|
|
package/ui/needs.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { mdInline } from './changelog-md.js'
|
|
11
11
|
import { tipHtml, tipId } from './tips.js'
|
|
12
|
+
import { composerBoxHtml } from './composer.js'
|
|
12
13
|
|
|
13
14
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
14
15
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
@@ -41,7 +42,7 @@ export function needsLine(n) {
|
|
|
41
42
|
// Dismiss (0.6.5): hides this one need on every board; a new question shows again.
|
|
42
43
|
export function dismissButton(n) {
|
|
43
44
|
if (!n || !n.key) return ''
|
|
44
|
-
return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss"
|
|
45
|
+
return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" data-tip="Dismiss: hide this one (a new question shows again)">✕</button>'
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
|
|
@@ -100,9 +101,9 @@ export function needPanel(n, opts = {}) {
|
|
|
100
101
|
let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
|
|
101
102
|
h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
|
|
102
103
|
(opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
|
|
103
|
-
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote"
|
|
104
|
+
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" data-tip="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
|
|
104
105
|
'<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
|
|
105
|
-
(n.waitSec != null ? '<span class="need-wait"
|
|
106
|
+
(n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
|
|
106
107
|
h += needContextHtml(n, opts) + prCardHtml(n.pr)
|
|
107
108
|
if (n.kind === 'permission' && n.ask) {
|
|
108
109
|
const d = n.ask.describe || {}
|
|
@@ -112,7 +113,7 @@ export function needPanel(n, opts = {}) {
|
|
|
112
113
|
h += '<div class="need-actions">' +
|
|
113
114
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
114
115
|
alwaysButton(n.ask, dis, { session: s }) +
|
|
115
|
-
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + '
|
|
116
|
+
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' data-tip="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
116
117
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
117
118
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
|
118
119
|
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
|
@@ -123,14 +124,14 @@ export function needPanel(n, opts = {}) {
|
|
|
123
124
|
h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
|
|
124
125
|
'<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
|
|
125
126
|
if (q.options.length) {
|
|
126
|
-
h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis +
|
|
127
|
+
h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
|
|
127
128
|
}
|
|
128
129
|
h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
|
|
129
130
|
h += '</div>'
|
|
130
131
|
})
|
|
131
132
|
h += quickRepliesHtml(n, dis)
|
|
132
133
|
h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
|
|
133
|
-
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + '
|
|
134
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' data-tip="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
|
|
134
135
|
} else if (n.kind === 'terminal') {
|
|
135
136
|
h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
|
|
136
137
|
'<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
@@ -138,8 +139,12 @@ export function needPanel(n, opts = {}) {
|
|
|
138
139
|
h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
|
|
139
140
|
if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
|
|
140
141
|
if (n.canReply) {
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
// 0.9.8: the same composer as Live (ui/composer.js): rounded box, round ↑,
|
|
143
|
+
// Enter sends on a Mac (⌘↵ everywhere). A reply is text only (Claude reads
|
|
144
|
+
// it as its next prompt), so there is no 📎 here.
|
|
145
|
+
h += composerBoxHtml({ submit: 'reply', sendAction: 'reply-send', sid: s.id, draftKey: 'reply:' + s.id, draft: drafts['reply:' + s.id] || '',
|
|
146
|
+
may, busy: !!busy, placeholder: 'Reply to Claude…', label: 'Reply to Claude', boxClass: 'reply-composer', inputClass: 'reply-text',
|
|
147
|
+
attach: false, keys: opts.composerKeys || KBD_SUBMIT }) + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
|
|
143
148
|
} else {
|
|
144
149
|
h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
145
150
|
}
|
|
@@ -191,8 +196,8 @@ export function agentChipHtml(a = {}, { title } = {}) {
|
|
|
191
196
|
const inner = esc(a.icon || '◆') + ' ' + esc(who)
|
|
192
197
|
const t = esc(title || 'external agent · read-only')
|
|
193
198
|
const url = httpsOnly(a.url)
|
|
194
|
-
return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer"
|
|
195
|
-
: '<span class="chip machine agent"
|
|
199
|
+
return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + t + '">' + inner + '</a>'
|
|
200
|
+
: '<span class="chip machine agent" data-tip="' + t + '">' + inner + '</span>'
|
|
196
201
|
}
|
|
197
202
|
// "From Tab · reply by texting Tab", or "From Tab · Read-only here".
|
|
198
203
|
export function agentReplyNote(a = {}) {
|
|
@@ -408,7 +413,7 @@ export function prCardHtml(pr) {
|
|
|
408
413
|
const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
|
|
409
414
|
(ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
|
|
410
415
|
'<span class="pr-title">' + esc(pr.title || '') + '</span>'
|
|
411
|
-
return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer"
|
|
416
|
+
return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
|
|
412
417
|
: '<div class="need-pr">' + inner + '</div>'
|
|
413
418
|
}
|
|
414
419
|
|
package/ui/start.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 0.9.9 New session (docs/plans/start-from-board.md): pick a Mac, a repo it
|
|
2
|
+
// has run Claude Code in, where (the repo, or a new worktree), and the first
|
|
3
|
+
// message (the Live composer, ui/composer.js); Leftoff starts Claude Code there
|
|
4
|
+
// and the session shows on every board right away. Inline on a phone: plain
|
|
5
|
+
// lists of big buttons, no menus or popovers. Pure HTML builders, shared with
|
|
6
|
+
// the unit tests; app.js wires them.
|
|
7
|
+
import { composerBoxHtml } from './composer.js'
|
|
8
|
+
|
|
9
|
+
const REPOS_SHOWN = 6
|
|
10
|
+
|
|
11
|
+
// The choice the page shows: what you picked, else this Mac (or the first
|
|
12
|
+
// online one), its newest repo, and "In the repo".
|
|
13
|
+
export function startPick(data, ui = {}) {
|
|
14
|
+
const ms = (data && data.machines) || []
|
|
15
|
+
const m = ms.find((x) => x.machine === ui.machine && x.online) || ms.find((x) => x.here && x.online && x.repos.length) || ms.find((x) => x.online && x.repos.length) || ms.find((x) => x.here) || ms[0] || null
|
|
16
|
+
const repos = m ? m.repos : []
|
|
17
|
+
const repo = repos.find((r) => r.path === ui.repo) || repos[0] || null
|
|
18
|
+
return { m, repo, where: ui.where === 'worktree' ? 'worktree' : 'repo' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// What happens when you press ↑, in one sentence (or why you can't).
|
|
22
|
+
export function startRule(data, pick, { local = true } = {}) {
|
|
23
|
+
const m = pick.m
|
|
24
|
+
if (!m) return { ok: false, text: 'No Mac to start on.' }
|
|
25
|
+
if (!local && !data.trust) return { ok: false, text: 'Turn on Trust my paired devices on a Mac first (Settings → Privacy & security), so you can answer its prompts from here.' }
|
|
26
|
+
if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
|
|
27
|
+
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Open Claude Code there once and it shows up here.' }
|
|
28
|
+
if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
|
|
29
|
+
if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
|
|
30
|
+
if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
|
|
31
|
+
return { ok: true, text: 'It starts right away if ' + m.machine + ' allows starting sessions from other devices; otherwise someone there confirms it first.' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ago = (iso, now) => {
|
|
35
|
+
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''
|
|
36
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
37
|
+
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm ago'
|
|
38
|
+
if (s < 86400) return Math.round(s / 3600) + 'h ago'
|
|
39
|
+
return Math.round(s / 86400) + 'd ago'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
43
|
+
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
|
|
44
|
+
'<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The waiting line after a start that has to be confirmed at the Mac.
|
|
48
|
+
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
49
|
+
if (!ask) return null
|
|
50
|
+
if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' }
|
|
51
|
+
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
52
|
+
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
53
|
+
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
54
|
+
if (ask.state === 'failed') return { tone: 'bad', text: 'Didn’t start on ' + ask.machine + ': ' + (ask.error || 'unknown error') }
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false } = {}) {
|
|
59
|
+
const pick = startPick(data, ui)
|
|
60
|
+
const rule = startRule(data, pick, { local })
|
|
61
|
+
let h = '<a class="back" href="#/">← All sessions</a>' +
|
|
62
|
+
'<div class="pagehead"><div><h1>New session</h1><div class="sub">Leftoff starts Claude Code on the Mac you pick. It shows on every board right away: talk to it in Live, approve its edits and commands in Needs you.</div></div></div>'
|
|
63
|
+
h += '<div class="start" data-start>'
|
|
64
|
+
// Mac
|
|
65
|
+
const ms = (data && data.machines) || []
|
|
66
|
+
h += '<section class="start-sec"><h2 class="start-h">Mac</h2><div class="start-opts" role="radiogroup" aria-label="Mac">' +
|
|
67
|
+
ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
|
|
68
|
+
label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
|
|
69
|
+
'</div></section>'
|
|
70
|
+
// Repo
|
|
71
|
+
const repos = pick.m ? pick.m.repos : []
|
|
72
|
+
const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
|
|
73
|
+
const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
|
|
74
|
+
h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
|
|
75
|
+
if (!repos.length) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
76
|
+
else {
|
|
77
|
+
h += '<div class="start-opts" role="radiogroup" aria-label="Repo">' + shown.map((r) => opt({ k: 'repo', v: r.path, on: !!pick.repo && r.path === pick.repo.path, esc,
|
|
78
|
+
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') })).join('') + '</div>'
|
|
79
|
+
if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
|
|
80
|
+
h += '<p class="start-hint">Only repos ' + esc(pick.m.machine) + ' has already run Claude Code in.</p>'
|
|
81
|
+
}
|
|
82
|
+
h += '</section>'
|
|
83
|
+
// Where
|
|
84
|
+
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
85
|
+
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
86
|
+
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
|
|
87
|
+
'</div></section>'
|
|
88
|
+
// First message
|
|
89
|
+
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
90
|
+
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
91
|
+
'</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
|
|
92
|
+
'<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
|
|
93
|
+
const line = startAskLine(ui.ask, { clock })
|
|
94
|
+
if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
|
|
95
|
+
h += '</section></div>'
|
|
96
|
+
return h
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A request waiting on THIS Mac: its board shows it with Start and Decline
|
|
100
|
+
// (only this Mac's own browser may answer).
|
|
101
|
+
export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
|
|
102
|
+
const asks = info && Array.isArray(info.pending) ? info.pending : []
|
|
103
|
+
if (!asks.length) return ''
|
|
104
|
+
return asks.map((a) => {
|
|
105
|
+
const b = busy.has(a.id)
|
|
106
|
+
return '<div class="banner warn start-ask-banner" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + esc(a.repo) + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '” <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
107
|
+
(local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
|
|
108
|
+
'<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
|
|
109
|
+
}).join('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The session page's line for a session Leftoff runs: where it runs, who
|
|
113
|
+
// started it, and Stop / Resume. The error (Claude Code's own words) is put
|
|
114
|
+
// in with textContent by app.js ([data-headless-err]).
|
|
115
|
+
export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
|
|
116
|
+
const h = s && s.headless
|
|
117
|
+
if (!h) return ''
|
|
118
|
+
const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
|
|
119
|
+
const where = s.machine ? ' on ' + s.machine : ''
|
|
120
|
+
let text, btn = ''
|
|
121
|
+
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
122
|
+
else if (h.state === 'stopping') text = 'Stopping' + where + '…'
|
|
123
|
+
else {
|
|
124
|
+
text = (h.state === 'paused' ? 'Paused' + where + ' (Leftoff restarted)' : h.error ? 'Claude Code stopped' + where : 'Stopped' + where) + (from ? ', started' + from : '')
|
|
125
|
+
if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
126
|
+
}
|
|
127
|
+
return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
|
|
128
|
+
(h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Settings → Privacy & security: the one switch, on this Mac only.
|
|
132
|
+
export function startsSettingHtml(info, { esc, local = true } = {}) {
|
|
133
|
+
if (!info) return ''
|
|
134
|
+
return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
|
|
135
|
+
'<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
|
|
136
|
+
}
|