@lovek4d/kickflip 0.7.1 → 0.7.4
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/CONTEXT.md +25 -21
- package/SKILL.md +13 -9
- package/bin/event-lines.mjs +1 -1
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +1 -1
- package/bin/wait-events.mjs +2 -2
- package/board/bin/board.mjs +68 -50
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -1
- package/board/onboarding.mjs +3 -3
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +2 -14
- package/board/public/app-banner.js +8 -3
- package/board/public/app-cards.js +7 -20
- package/board/public/app-core.js +2 -2
- package/board/public/app-lanes.js +7 -15
- package/board/public/app-reader.js +7 -3
- package/board/public/app-snapshot.js +1 -1
- package/board/public/app.js +1 -1
- package/board/public/attach.js +18 -2
- package/board/public/dialogs.js +10 -12
- package/board/public/hub.css +0 -3
- package/board/public/linear-key.js +1 -1
- package/board/public/sidebar.css +1 -1
- package/board/public/style.css +42 -36
- package/board/public/ui-cards.js +33 -27
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-links.js +0 -1
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-questions.js +8 -6
- package/board/public/ui-sidebar.js +1 -1
- package/board/public/ui-threads.js +26 -22
- package/board/public/ui.js +15 -9
- package/board/public/view-hub-settings.js +25 -30
- package/board/public/view-hub.js +23 -5
- package/board/public/view-questions.js +1 -1
- package/board/public/view-settings.js +4 -4
- package/board/server.mjs +18 -18
- package/board/store.mjs +30 -27
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +15 -15
- package/hub/board-repo.mjs +1 -1
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/linear.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/pr-report.mjs +2 -2
- package/hub/readiness.mjs +1 -1
- package/hub/repos.mjs +3 -3
- package/hub/review-send.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/slack.mjs +1 -1
- package/hub/try.mjs +8 -6
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/fake-gh.mjs +19 -4
- package/shared/gh.mjs +4 -0
- package/shared/owner-check.mjs +4 -3
- package/shared/service.mjs +12 -12
- package/starter/server.mjs +3 -3
package/board/public/style.css
CHANGED
|
@@ -73,6 +73,8 @@ main { max-width: 860px; margin: 0 auto; padding: 12px 16px 80px; }
|
|
|
73
73
|
button.chip { border: 0; }
|
|
74
74
|
.chip.tone-quiet { color: var(--color-quiet); } .chip.tone-normal { color: var(--color-info); } .chip.tone-notice { color: var(--color-notice); }
|
|
75
75
|
.chip.tone-danger { color: var(--color-danger); } .chip.tone-ready { color: var(--color-success); } .chip.tone-merged { color: var(--color-merged); }
|
|
76
|
+
/* A PR's chips while GitHub's limit holds the hub's check (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md) */
|
|
77
|
+
.chip[data-limited] { opacity: .45; }
|
|
76
78
|
/* Action needed keeps the notice orange; unread takes the accent so the two never read alike. */
|
|
77
79
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--color-quiet); }
|
|
78
80
|
.dot.pass { background: var(--color-success); } .dot.pending { background: var(--color-notice); } .dot.fail { background: var(--color-danger); }
|
|
@@ -141,7 +143,7 @@ button.chip { border: 0; }
|
|
|
141
143
|
.thread-more:active { background: var(--accent-soft); }
|
|
142
144
|
.thread-more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
143
145
|
.thread-more .n { color: var(--muted); font-weight: 400; }
|
|
144
|
-
/* [answered asks
|
|
146
|
+
/* [answered asks fold into the one Done list](docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md): Done's Show more, the thread's look as a row of the lane's list */
|
|
145
147
|
.list > .thread-more { width: 100%; margin: 0; border-top: 1px solid var(--line); border-radius: 0; }
|
|
146
148
|
|
|
147
149
|
@media (max-width: 560px) {
|
|
@@ -152,9 +154,8 @@ button.chip { border: 0; }
|
|
|
152
154
|
}
|
|
153
155
|
.calls ul { margin: 4px 0 8px; padding-left: 18px; font-size: 13px; }
|
|
154
156
|
button.link { background: none; border: 0; color: var(--accent); padding: 8px 12px; font-size: 13px; cursor: pointer; }
|
|
155
|
-
.
|
|
156
|
-
.
|
|
157
|
-
.wv-link:disabled { color: var(--muted); cursor: default; }
|
|
157
|
+
.wb-chevron { flex: none; color: var(--muted); }
|
|
158
|
+
.wb[data-open] > .wb-head .wb-chevron { transform: rotate(90deg); }
|
|
158
159
|
.wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
|
|
159
160
|
/* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
|
|
160
161
|
.wb + .wb { border-top: 1px solid var(--line); }
|
|
@@ -162,15 +163,11 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
162
163
|
background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
|
|
163
164
|
.wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
|
|
164
165
|
.wb-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
165
|
-
.wb-head .wave-icon { color: var(--muted); }
|
|
166
166
|
.wb-head .count { color: var(--muted); font-weight: 400; }
|
|
167
|
-
.wb
|
|
167
|
+
.wb-body { border-top: 1px solid var(--line); }
|
|
168
168
|
.wb-label { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; }
|
|
169
169
|
.wb-name { -webkit-touch-callout: none; }
|
|
170
|
-
.wb-end { display: flex; align-items: baseline; gap: 10px; margin-left: auto; font-weight: 400; }
|
|
171
170
|
.wb-links { display: inline-flex; align-items: baseline; white-space: nowrap; }
|
|
172
|
-
.wb-next { color: var(--muted); white-space: nowrap; }
|
|
173
|
-
.wb-head .wv-link { font-weight: 500; }
|
|
174
171
|
.wb-pop { position: absolute; left: 34px; right: 12px; top: calc(100% - 2px); z-index: 6; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px;
|
|
175
172
|
background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; line-height: 1.35; overflow-wrap: anywhere; cursor: default; }
|
|
176
173
|
.wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
|
|
@@ -303,7 +300,7 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
|
|
|
303
300
|
}
|
|
304
301
|
/* The hub lives at the tailnet root, outside this site, so its link is root-absolute. */
|
|
305
302
|
.brand { align-items: center; }
|
|
306
|
-
.hub { display: inline-flex; flex: none; }
|
|
303
|
+
a.hub { display: inline-flex; flex: none; }
|
|
307
304
|
.hub img { width: 32px; height: auto; display: block; image-rendering: pixelated; }
|
|
308
305
|
@keyframes pulse { 50% { opacity: .35; } }
|
|
309
306
|
/* A lane's folds are inset boxes in its list, so they read as part of that lane, not as more lanes. */
|
|
@@ -378,13 +375,18 @@ button.attach:disabled { opacity: .35; }
|
|
|
378
375
|
/* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
|
|
379
376
|
.pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
|
|
380
377
|
.pickup-wait .working-dot { background: currentColor; animation: none; }
|
|
381
|
-
|
|
382
|
-
.
|
|
378
|
+
/* Every status line shares this spacing and gutter: the mark sits in the first 23px, and wrapped text hangs under the
|
|
379
|
+
first line's text, never under the mark. A fold's summary hangs the same way inside its box. Each kind keeps only its colour. */
|
|
380
|
+
.status-line { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
381
|
+
div.status-line { padding-left: 33px; text-indent: -23px; }
|
|
382
|
+
.status-line > summary { padding-left: 23px; text-indent: -23px; }
|
|
383
|
+
.status-line > ol { text-indent: 0; }
|
|
384
|
+
.msg.builder-status { color: var(--color-notice); }
|
|
383
385
|
.msg.builder-status .working-dot { animation: none; }
|
|
384
|
-
.msg.request {
|
|
386
|
+
.msg.request { color: var(--muted); }
|
|
385
387
|
.msg.request.readied { color: var(--color-success); }
|
|
386
388
|
.msg.request.hub.fault { color: var(--color-danger); }
|
|
387
|
-
.msg.agent {
|
|
389
|
+
.msg.agent { font-weight: 600; }
|
|
388
390
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
389
391
|
.msg.agent .muted { font-weight: 400; }
|
|
390
392
|
/* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
|
|
@@ -407,7 +409,6 @@ button.attach:disabled { opacity: .35; }
|
|
|
407
409
|
.sub.agent.stale .working-dot { animation: none; }
|
|
408
410
|
/* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
|
|
409
411
|
.paused { color: var(--muted); font-weight: 600; }
|
|
410
|
-
.msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
411
412
|
.msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
412
413
|
.msg.calls summary::-webkit-details-marker { display: none; }
|
|
413
414
|
.msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
|
|
@@ -641,14 +642,18 @@ main > * { transition: opacity .25s ease; }
|
|
|
641
642
|
.view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
642
643
|
.view-questions .opt .det { color: var(--muted); font-size: 13px; }
|
|
643
644
|
/* A question's context and an option's detail take markdown at body size: headings drawn as bold lines, blocks tight
|
|
644
|
-
(docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md).
|
|
645
|
-
:
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
:is(.
|
|
649
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det
|
|
650
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) table {
|
|
651
|
-
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det)
|
|
645
|
+
(docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). So does any .md
|
|
646
|
+
box: the orchestrator's thread messages, markdown replies and View transcript's text rows
|
|
647
|
+
(docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
|
|
648
|
+
lines, not the text's own newlines. */
|
|
649
|
+
:is(.msg .body, .reader-row).md { white-space: normal; }
|
|
650
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
|
|
651
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
|
|
652
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
|
|
653
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
|
|
654
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
|
|
655
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
|
|
656
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) > :last-child { margin-bottom: 0; }
|
|
652
657
|
.view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
|
|
653
658
|
.view-questions .note { margin-top: 4px; }
|
|
654
659
|
.view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
|
|
@@ -732,18 +737,18 @@ main > * { transition: opacity .25s ease; }
|
|
|
732
737
|
.view-notes .plan li { margin: 3px 0; }
|
|
733
738
|
.view-notes .plan li.nested { margin-left: 20px; }
|
|
734
739
|
.view-notes .plan code { font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 0 3px; }
|
|
735
|
-
.view-notes .plan pre { overflow-x:
|
|
736
|
-
.view-notes .plan pre code { background: none; padding: 0;
|
|
740
|
+
.view-notes .plan pre { white-space: pre-wrap; overflow-x: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
|
741
|
+
.view-notes .plan pre code { background: none; padding: 0; }
|
|
737
742
|
.view-notes .plan blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
|
|
738
743
|
.view-notes .plan hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0; }
|
|
739
744
|
.view-notes .plan a { color: var(--accent); }
|
|
740
|
-
.view-notes .md-table { overflow-x:
|
|
741
|
-
.view-notes .md-table table { border-collapse: collapse; font-size: 13px; line-height: 1.4; }
|
|
742
|
-
.view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line);
|
|
745
|
+
.view-notes .md-table { overflow-x: hidden; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
|
746
|
+
.view-notes .md-table table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.4; }
|
|
747
|
+
.view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
|
743
748
|
.view-notes .md-table th { background: var(--bg); font-weight: 600; }
|
|
744
749
|
.view-notes .md-table tr:last-child td { border-bottom: 0; }
|
|
745
|
-
|
|
746
|
-
.view-notes .
|
|
750
|
+
/* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
|
|
751
|
+
.view-questions, .view-notes, .reader-sheet, .pane-viewer { overflow-x: hidden; }
|
|
747
752
|
/* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
|
|
748
753
|
.view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
|
749
754
|
.view-frame > .top { flex: none; }
|
|
@@ -829,18 +834,18 @@ main > * { transition: opacity .25s ease; }
|
|
|
829
834
|
its content gives way to a spinner in its text colour; the New version row's spinner sits in its pill; a bump icon keeps
|
|
830
835
|
its arrow and pulses orange until the card moves; a toggle, select or field keeps its face with a spinner beside it */
|
|
831
836
|
button[aria-busy="true"], button[aria-busy="true"]:disabled, .ask-actions .icon[aria-busy="true"]:disabled { cursor: default; opacity: 1; }
|
|
832
|
-
:is(button[aria-busy="true"]:not([data-card-bump],
|
|
833
|
-
:is(button[aria-busy="true"]:not([data-card-bump],
|
|
834
|
-
:is(button[aria-busy="true"]:not([data-card-bump],
|
|
837
|
+
:is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) { position: relative; -webkit-text-fill-color: transparent; }
|
|
838
|
+
:is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) > * { visibility: hidden; }
|
|
839
|
+
:is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go)::after { content: ''; position: absolute; inset: 0; margin: auto;
|
|
835
840
|
width: 12px; height: 12px; box-sizing: border-box; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: try-spin .8s linear infinite; }
|
|
836
841
|
@keyframes wait-orange { 50% { color: var(--color-notice); opacity: 1; } }
|
|
837
|
-
button[aria-busy="true"]:is([data-card-bump]
|
|
838
|
-
button[aria-busy="true"]:is([data-card-bump]
|
|
842
|
+
button[aria-busy="true"]:is([data-card-bump]) { color: var(--muted); animation: wait-orange 1s ease-in-out infinite; }
|
|
843
|
+
button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
839
844
|
.try-spin.tap-beside { width: 12px; height: 12px; border-width: 2px; margin: 0 8px 0 auto; color: var(--muted); }
|
|
840
845
|
.tk-field > span > .try-spin.tap-beside { margin: 0 0 0 6px; vertical-align: -2px; }
|
|
841
846
|
@media (prefers-reduced-motion: reduce) {
|
|
842
847
|
:is(button[aria-busy="true"], .sb-update-row[aria-busy="true"] .sb-go)::after { animation: none; }
|
|
843
|
-
button[aria-busy="true"]:is([data-card-bump]
|
|
848
|
+
button[aria-busy="true"]:is([data-card-bump]) { animation: none; color: var(--color-notice); }
|
|
844
849
|
}
|
|
845
850
|
.install { display: inline-flex; align-items: center; gap: 4px; }
|
|
846
851
|
.external-links .install.tone-ready { color: var(--color-success); }
|
|
@@ -996,3 +1001,4 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
996
1001
|
}
|
|
997
1002
|
/* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
|
|
998
1003
|
body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
1004
|
+
.pfile.retry { cursor: pointer; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-progress-bar.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge,
|
|
3
|
+
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
5
|
// The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
|
|
6
6
|
// title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
|
|
@@ -24,14 +24,21 @@
|
|
|
24
24
|
const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
|
|
25
25
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
26
26
|
const live = pr.state === 'OPEN';
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md: while GitHub's limit holds the hub's check
|
|
28
|
+
// (pr.limited, its reset), the PR's chips dim and their titles say when it resets.
|
|
29
|
+
const note = pr.limited ? ` · GitHub rate limited, resets at ${clock(new Date(pr.limited))}` : '';
|
|
30
|
+
const dim = pr.limited ? { 'data-limited': '' } : {};
|
|
31
|
+
const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}${note}`, attrs: dim }) : '';
|
|
32
|
+
const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet${note}`, attrs: dim }) : '';
|
|
29
33
|
const name = prName(pr);
|
|
30
|
-
const label = look.label ? { title: look.label
|
|
34
|
+
const label = look.label ? { title: `${look.label}${note}`, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}`, ...dim } } : { title: note.slice(3) || undefined, attrs: { ...EXTERNAL, ...dim } };
|
|
31
35
|
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
32
36
|
}
|
|
33
37
|
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
34
|
-
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === '
|
|
38
|
+
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: EXTERNAL });
|
|
39
|
+
// docs/adr/2026-11-09-1500-a-task-is-a-card-with-no-ticket-keyed-t-n.md: a card with no ticket is a task, its T-<n>
|
|
40
|
+
// only in the badge's title.
|
|
41
|
+
const taskChip = (it) => badge('quiet', 'Task', { title: `${it.ref} · a task, no ticket` });
|
|
35
42
|
const REVIEW = {
|
|
36
43
|
approved: { tone: 'ready', mark: '✓', label: 'approved' },
|
|
37
44
|
changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
|
|
@@ -128,9 +135,9 @@
|
|
|
128
135
|
return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
|
|
129
136
|
}
|
|
130
137
|
const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
131
|
-
// Main's refresh icon on a
|
|
138
|
+
// Main's refresh icon on a board with an origin (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
|
|
132
139
|
// the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
|
|
133
|
-
// server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a
|
|
140
|
+
// server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover that opens when a pull fails
|
|
134
141
|
// (`open`). Visit is left alone: it changes only once Main rebuilds.
|
|
135
142
|
function mainPull(pull, { open = false } = {}) {
|
|
136
143
|
if (!pull) return '';
|
|
@@ -140,14 +147,18 @@
|
|
|
140
147
|
const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
|
|
141
148
|
return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
|
|
142
149
|
}
|
|
150
|
+
// While Main re-serves, or after its new build failed, the old server still serves (main.serving): the bar names its
|
|
151
|
+
// commit and keeps Visit to it, with Serving… or Failed left of Visit.
|
|
143
152
|
function mainBar(main, pulling) {
|
|
144
153
|
if (!main) return '';
|
|
145
154
|
const words = tryWords(main.kind);
|
|
146
155
|
const url = tryURL(main);
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
: main.state === '
|
|
150
|
-
|
|
156
|
+
const old = main.serving;
|
|
157
|
+
const status = main.state === 'running' ? badge('notice', spinner + esc(old ? 'Serving…' : words.going), { attrs: { role: 'status' } })
|
|
158
|
+
: main.state === 'failed' ? badge('danger', esc(old ? 'Failed' : words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' }) : '';
|
|
159
|
+
const visit = (main.state === 'ready' || old) && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
160
|
+
const shown = old || main;
|
|
161
|
+
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(shown.sha)}</code> · ${esc(ago(shown.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${status}${visit}</div></div>`;
|
|
151
162
|
}
|
|
152
163
|
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
|
|
153
164
|
// server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
|
|
@@ -311,27 +322,24 @@
|
|
|
311
322
|
return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
|
|
312
323
|
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
313
324
|
}
|
|
314
|
-
// A New card's bump, edit and delete, and its line from its ask
|
|
315
|
-
// docs/adr/2026-
|
|
316
|
-
const askActions = (it
|
|
325
|
+
// A New card's bump, edit and delete: it bumps as a card, and edits and deletes its ask; its line comes from its ask
|
|
326
|
+
// state (docs/adr/2026-11-09-1900-every-ask-is-a-task-card-from-the-moment-it-arrives.md).
|
|
327
|
+
const askActions = (it) => `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}${editButton(it.ask.eventId)}${withdrawButton(it.ask.eventId)}</div>`;
|
|
317
328
|
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
318
329
|
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
319
330
|
const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
320
331
|
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
321
|
-
const cardActions = (it
|
|
332
|
+
const cardActions = (it) => (it.isNew ? askActions(it) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
322
333
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
323
334
|
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
324
335
|
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
325
336
|
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
326
337
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
327
|
-
function workLines(it, { unread = 0
|
|
328
|
-
const ticket = it.ticket ? ticketChip(it) : it.
|
|
329
|
-
// A
|
|
330
|
-
const ids = it.
|
|
338
|
+
function workLines(it, { unread = 0 } = {}) {
|
|
339
|
+
const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
|
|
340
|
+
// A card shows its ticket, or Task when it has none; the card an ask became shows New there until it is sorted.
|
|
341
|
+
const ids = it.isNew ? badge('notice', 'New') : ticket;
|
|
331
342
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
332
|
-
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
333
|
-
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
334
|
-
const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
|
|
335
343
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
336
344
|
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
337
345
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
@@ -345,13 +353,11 @@
|
|
|
345
353
|
// The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
|
|
346
354
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
347
355
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
348
|
-
return `${cardActions(it
|
|
349
|
-
${
|
|
356
|
+
return `${cardActions(it)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
357
|
+
${status}${check}${eta}
|
|
350
358
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
351
359
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
|
352
360
|
}
|
|
353
|
-
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
354
|
-
const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
|
|
355
361
|
function planButton(items) {
|
|
356
362
|
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
|
|
357
363
|
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
|
|
@@ -395,5 +401,5 @@
|
|
|
395
401
|
<div class="list">${list}${fold}</div></details>`;
|
|
396
402
|
}
|
|
397
403
|
|
|
398
|
-
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines,
|
|
404
|
+
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askActions, askLine, planButton, reviewLane });
|
|
399
405
|
})();
|
package/board/public/ui-enums.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
|
|
4
4
|
// since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
5
|
(function () {
|
|
6
|
-
const BUTTON = Object.freeze({ openQuestions: 'openQuestions',
|
|
7
|
-
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge',
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply' });
|
|
7
|
+
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
10
|
// Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
package/board/public/ui-links.js
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
const file = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
21
21
|
const byRef = new Map();
|
|
22
22
|
for (const i of items) {
|
|
23
|
-
if (i.kind === 'ask') continue;
|
|
24
23
|
for (const ref of new Set([i.ref, i.id])) {
|
|
25
24
|
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
|
|
26
25
|
byRef.set(ref, [...(byRef.get(ref) || []), i]);
|
package/board/public/ui-order.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
|
|
4
4
|
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
5
|
(function () {
|
|
6
|
-
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3
|
|
6
|
+
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3 };
|
|
7
7
|
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
8
8
|
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
9
9
|
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md). Todo lists the rest oldest first by sortAt, a card with no
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText,
|
|
3
|
+
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
4
|
// ui-markdown.js loads after this file, so markdown is looked up when a question draws.
|
|
5
5
|
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
6
6
|
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
@@ -44,10 +44,12 @@
|
|
|
44
44
|
: r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
|
|
45
45
|
: badge('normal', `Prototype round ${r.n}`);
|
|
46
46
|
const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
|
|
47
|
+
// Each of an answer's replies, oldest first.
|
|
48
|
+
const repliesOf = (a, links) => (a.replies?.length ? a.replies : a.reply ? [a.reply] : []).map((r) => `<div class="rep${r.md ? ' md' : ''}"><div class="muted small">Claude</div>${replyBody(r, links)}</div>`).join('');
|
|
47
49
|
function answerHistory(q, links = {}) {
|
|
48
50
|
if (!q.answers.length) return '';
|
|
49
51
|
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
|
|
50
|
-
${
|
|
52
|
+
${repliesOf(a, links)}`).join('');
|
|
51
53
|
const last = q.answers[q.answers.length - 1];
|
|
52
54
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
53
55
|
}
|
|
@@ -99,7 +101,7 @@
|
|
|
99
101
|
const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
|
|
100
102
|
const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
|
|
101
103
|
const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
|
|
102
|
-
${
|
|
104
|
+
${repliesOf(last, links)}` : '';
|
|
103
105
|
return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
104
106
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
|
|
105
107
|
<h3>${linkText(q.question, links)}</h3>
|
|
@@ -131,15 +133,15 @@
|
|
|
131
133
|
${answerHistory(q, links)}
|
|
132
134
|
</section>`;
|
|
133
135
|
}
|
|
134
|
-
// A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from
|
|
135
|
-
const topicName = (t) =>
|
|
136
|
+
// A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from.
|
|
137
|
+
const topicName = (t) => t.title;
|
|
136
138
|
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
137
139
|
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
138
140
|
function cardTopics(topics, cards) {
|
|
139
141
|
const byKey = new Map(cards.map((c) => [c.key, c]));
|
|
140
142
|
return topics.map((t) => {
|
|
141
143
|
const c = t.title === undefined && byKey.get(t.card);
|
|
142
|
-
return c ? { ...t, title: `${c.ref}: ${c.title}
|
|
144
|
+
return c ? { ...t, title: `${c.ref}: ${c.title}` } : t;
|
|
143
145
|
});
|
|
144
146
|
}
|
|
145
147
|
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
+ '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
const ticketsLabel = (t) => (t.source === '
|
|
132
|
+
const ticketsLabel = (t) => (t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : t.source === 'linear' ? `Linear · ${t.team}` : 'None');
|
|
133
133
|
// A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
|
|
134
134
|
// reading is older than ten minutes, with that age in its tooltip.
|
|
135
135
|
const CTX_STALE_MIN = 10;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
|
|
2
|
-
// asks, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-
|
|
2
|
+
// asks, the owner's messages and their replies. Needs ui.js, ui-links.js, ui-enums.js and ui-markdown.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark
|
|
4
|
+
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
|
+
// ui-markdown.js loads after this file, so markdown is looked up when a thread draws.
|
|
7
|
+
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
8
|
+
// The orchestrator's own messages are markdown in compact styles; the owner's, a shared person's and GitHub's or
|
|
9
|
+
// Slack's stay as written (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
|
|
10
|
+
const drawsMarkdown = (m) => m.who === 'claude' && !m.html;
|
|
6
11
|
// A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
|
|
7
12
|
function cardLink(link) {
|
|
8
13
|
const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
|
|
@@ -25,9 +30,9 @@
|
|
|
25
30
|
const withReplies = (msgs) => {
|
|
26
31
|
// A folded submit's answers each reply, quoting their own block.
|
|
27
32
|
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
28
|
-
...(m.answers || [m]).filter((a) => a.eventId && a.reply).
|
|
33
|
+
...(m.answers || [m]).filter((a) => a.eventId && a.reply).flatMap((a) => (a.replies?.length ? a.replies : [a.reply]).map((reply, k) => ({ m: { ts: reply.at || m.ts, who: 'claude', kind: 'reply', reply }, of: m, quoted: m.answers ? a.text : m.text, later: k > 0, at: String(reply.at || m.ts), i, part: 1 })))]);
|
|
29
34
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
30
|
-
return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
35
|
+
return out.map(({ m, of, quoted, later }, n) => (of ? { ...m, quote: later || out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
31
36
|
};
|
|
32
37
|
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
33
38
|
// files it adds to the message, under composer id edit:<event id>.
|
|
@@ -35,26 +40,26 @@
|
|
|
35
40
|
<div class="pending-files" data-files></div>
|
|
36
41
|
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
37
42
|
const line = (m, links, edits) => (m.kind === 'reply'
|
|
38
|
-
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${
|
|
39
|
-
: m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
43
|
+
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body${m.reply.md ? ' md' : ''}">${replyBody(m.reply, links)}</div></div>`
|
|
44
|
+
: m.kind === 'ask-link' ? `<div class="msg status-line link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
40
45
|
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
|
|
41
|
-
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
46
|
+
: m.kind === 'visual' ? `<div class="msg status-line link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
47
|
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
|
-
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
-
: m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
48
|
+
: m.request && m.card ? `<div class="msg status-line request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
49
|
+
: m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
50
|
// docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
|
|
46
51
|
// own line, its kind's mark and words, red for a fault.
|
|
47
|
-
: m.kind === 'hub' ? `<div class="msg request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
52
|
+
: m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
48
53
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
49
|
-
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
50
|
-
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
54
|
+
: m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
55
|
+
: m.kind === 'questions' ? `<div class="msg status-line builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
51
56
|
: m.folded ? foldedRun(m, links, edits)
|
|
52
|
-
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
53
|
-
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
54
|
-
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
57
|
+
: m.kind === 'status' ? `<div class="msg status-line builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
58
|
+
: m.kind === 'calls' ? `<details class="msg status-line calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
59
|
+
: m.kind === 'agent' ? `<div class="msg status-line agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
55
60
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
56
61
|
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
57
|
-
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
62
|
+
<div class="body${drawsMarkdown(m) ? ' md' : ''}"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : drawsMarkdown(m) ? markdown(m.text, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
58
63
|
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
|
|
59
64
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
60
65
|
${m.eventId ? msgFooter(m) : ''}
|
|
@@ -68,7 +73,7 @@
|
|
|
68
73
|
const tail = m.folded.at(-1).finished ? 'finished' : 'running';
|
|
69
74
|
const log = m.folded.find((f) => f.log)?.log;
|
|
70
75
|
const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
|
|
71
|
-
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
76
|
+
return `<details class="msg status-line calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
|
|
72
77
|
}
|
|
73
78
|
function foldStatus(lines) {
|
|
74
79
|
const runs = new Map();
|
|
@@ -115,12 +120,11 @@
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
118
|
-
// An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends
|
|
119
|
-
//
|
|
123
|
+
// An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends card-done
|
|
124
|
+
// (app-cards.js); the board applies it before the session follows up
|
|
120
125
|
// (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
|
|
126
|
+
// Primary while a card waits only on it (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
127
|
+
const doneTone = (it) => (it.awaitsDone ? 'closes' : '');
|
|
124
128
|
function doneButton(it) {
|
|
125
129
|
const tone = doneTone(it);
|
|
126
130
|
return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
|
package/board/public/ui.js
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
|
|
36
36
|
return `${day}, ${at}`;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// A reply as HTML with its refs linked: a markdown reply drawn by ui-markdown.js, which, like ui-links.js, loads after
|
|
39
|
+
// this file; an older HTML one as written. Its box takes class md for a markdown reply's compact styles.
|
|
40
|
+
const replyBody = (reply, links) => (reply.md ? globalThis.BoardUI.markdown(reply.md, links)
|
|
41
|
+
: globalThis.BoardUI.linkHtml(reply.html ?? esc(JSON.stringify(reply.json)), links));
|
|
39
42
|
|
|
40
43
|
// Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
|
|
41
44
|
const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
|
|
@@ -127,11 +130,9 @@
|
|
|
127
130
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
128
131
|
const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
|
|
129
132
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
130
|
-
// A
|
|
131
|
-
//
|
|
132
|
-
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
133
|
+
// A card's bump: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where it is filled orange,
|
|
134
|
+
// card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
133
135
|
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
134
|
-
const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
135
136
|
const cardBumpButton = (key, bumped) => (bumped
|
|
136
137
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
137
138
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
@@ -151,11 +152,16 @@
|
|
|
151
152
|
return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
|
|
152
153
|
}
|
|
153
154
|
// A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
|
|
154
|
-
|
|
155
|
+
// An errored chip retries on click, except "Over 20 MB", which a retry can't change.
|
|
156
|
+
const pendingFiles = (list) => list.map((p) => {
|
|
157
|
+
const retry = p.status === 'error' && p.error !== 'Over 20 MB';
|
|
158
|
+
const title = retry ? `${p.name}: tap to retry` : p.status === 'error' ? `${p.name}: a retry can't succeed, remove it` : p.name;
|
|
159
|
+
return `<span class="pfile ${esc(p.status)}${retry ? ' retry' : ''}" title="${esc(title)}"${retry ? ` data-retry="${esc(p.id)}"` : ''}>
|
|
155
160
|
${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
|
|
156
161
|
${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
|
|
157
|
-
${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
|
|
158
|
-
<button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span
|
|
162
|
+
${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}${retry ? ' · tap to retry' : ''}</span>` : ''}
|
|
163
|
+
<button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`;
|
|
164
|
+
}).join('');
|
|
159
165
|
|
|
160
166
|
// Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
|
|
161
167
|
// being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
|
|
@@ -459,6 +465,6 @@
|
|
|
459
465
|
globalThis.BoardUI = {
|
|
460
466
|
LINK_ARROW, escText, onBackdrop,
|
|
461
467
|
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
462
|
-
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton,
|
|
468
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
463
469
|
};
|
|
464
470
|
})();
|