k0-board 0.4.0 → 0.5.0
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/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +109 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/web/board.js
CHANGED
|
@@ -3,6 +3,9 @@ import { initView, refit } from '/view.js'
|
|
|
3
3
|
import { weight, gb } from '/units.js'
|
|
4
4
|
import { setFavicon } from '/favicon.js'
|
|
5
5
|
import { split } from '/recency.js'
|
|
6
|
+
import { drawList, leaveList } from '/list.js'
|
|
7
|
+
import { search as fuzzy } from '/fuzzy.js'
|
|
8
|
+
import { whileVisible } from '/awake.js'
|
|
6
9
|
|
|
7
10
|
// ── Statuses ───────────────────────────────────────────────────────
|
|
8
11
|
// The order is the attention priority inside a column: first whoever is waiting for you,
|
|
@@ -28,13 +31,35 @@ const $ = (s) => document.querySelector(s)
|
|
|
28
31
|
const api = async (url, opts) => {
|
|
29
32
|
const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
|
|
30
33
|
const body = await r.json()
|
|
31
|
-
if (!r.ok)
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
// The refusal carries more than a sentence — the decisions the counter-check found broken
|
|
36
|
+
// come with it — and throwing only `body.error` was how they were lost between the server
|
|
37
|
+
// saying which rule was broken and the board telling the user something was.
|
|
38
|
+
const err = new Error(body.error || 'Something went wrong')
|
|
39
|
+
err.body = body
|
|
40
|
+
throw err
|
|
41
|
+
}
|
|
32
42
|
return body
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A refusal, with the decisions it is about named in it.
|
|
47
|
+
*
|
|
48
|
+
* `Run 3 found a decision broken` is true and useless on its own: it tells you to put something
|
|
49
|
+
* right without saying what. The labels are `K7·D3` — an epic's key in front when the rule came
|
|
50
|
+
* down from the epic — and they are the same names the panel and the `.k0/` file print, so
|
|
51
|
+
* one of them is enough to find it. This is the only moment k0 talks about a broken decision to
|
|
52
|
+
* somebody who is not already on the page where the evidence is.
|
|
53
|
+
*/
|
|
54
|
+
function refusal(e) {
|
|
55
|
+
const broken = e.body?.violations ?? []
|
|
56
|
+
if (!broken.length) return e.message
|
|
57
|
+
return `${e.message}\n\n${broken.map((v) => `${v.label} — ${v.text}`).join('\n')}`
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
let projects = []
|
|
36
|
-
let editing = null // id of the
|
|
37
|
-
let
|
|
61
|
+
let editing = null // id of the story open in the editor, null = a new one
|
|
62
|
+
let editingStory = null // the whole story: Start needs to know whether a session exists already
|
|
38
63
|
let chosenProject = null
|
|
39
64
|
let lastSignature = ''
|
|
40
65
|
|
|
@@ -52,10 +77,38 @@ const saveFolded = () => localStorage.setItem('k0-folded', JSON.stringify([...fo
|
|
|
52
77
|
// deliberately not remembered, so that reloading is what tidies the board up.
|
|
53
78
|
const held = new Set()
|
|
54
79
|
|
|
80
|
+
// Whether this board has a backlog behind it. The server holds the switch and says so on every
|
|
81
|
+
// round; with it off the board is the board it has always been, down to the buttons on a post-it.
|
|
82
|
+
let backlogOn = false
|
|
83
|
+
|
|
84
|
+
// Which shape the same stories come in: the board as it has always been, or a list. Remembered
|
|
85
|
+
// like the filters and the folded columns are, because it is the same kind of choice — how you
|
|
86
|
+
// want to look at the board, not what is on it. `kanban` is the default and the only answer the
|
|
87
|
+
// switch gives while the backlog is off.
|
|
88
|
+
//
|
|
89
|
+
// The key is the one the switch has always used, and only `list` is read out of it: a board that
|
|
90
|
+
// was left on the old `state` columns comes back as a kanban rather than as nothing, which is what
|
|
91
|
+
// a stored value nobody recognises would have meant.
|
|
92
|
+
let columnsMean = localStorage.getItem('k0-columns') === 'list' ? 'list' : 'kanban'
|
|
93
|
+
const byList = () => backlogOn && columnsMean === 'list'
|
|
94
|
+
|
|
95
|
+
// Which repository, for both views. Empty means all of them.
|
|
96
|
+
let repoFilter = localStorage.getItem('k0-repo-filter') || ''
|
|
97
|
+
|
|
98
|
+
// The epic the board is standing inside, `{ key, path }`, or null for the whole board. It
|
|
99
|
+
// survives a reload on purpose: going into an epic is where you decided to work, and a page that
|
|
100
|
+
// let go of it on every refresh would make it a place you cannot stay.
|
|
101
|
+
let laneEpic = readLane()
|
|
102
|
+
|
|
103
|
+
// Every epic k0 knows, kept from the last round: the editor's list is drawn from it, and so are
|
|
104
|
+
// the headings in the list view.
|
|
105
|
+
let allEpics = []
|
|
106
|
+
|
|
55
107
|
// The weight of each session: on to begin with, switched off from the gauge at the top.
|
|
56
108
|
let showLoad = localStorage.getItem('k0-load') !== '0'
|
|
57
109
|
let machine = null // how the computer is doing right now
|
|
58
|
-
let
|
|
110
|
+
let k0 = null // and what k0 itself is costing it
|
|
111
|
+
let heaviest = null // the story eating the most, so only that one gets tinted
|
|
59
112
|
|
|
60
113
|
// ── Render ─────────────────────────────────────────────────────────
|
|
61
114
|
/** Coarse-grained time: to the second it was only noise. */
|
|
@@ -70,64 +123,267 @@ function since(ts, now) {
|
|
|
70
123
|
return d === 1 ? 'yesterday' : `${d} days`
|
|
71
124
|
}
|
|
72
125
|
|
|
73
|
-
/** A stable tilt: it depends on the id, so a
|
|
126
|
+
/** A stable tilt: it depends on the id, so a story does not dance on every refresh. */
|
|
74
127
|
const tilt = (id) => (((id * 37) % 5) - 2) * 0.5
|
|
75
128
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
129
|
+
// ── What the backlog adds to a post-it ─────────────────────────────
|
|
130
|
+
// Four marks and one button. The marks are the name the story is called by, the epic it belongs to,
|
|
131
|
+
// whether it is waiting on something, and the flag; the button is the one thing to do with it next.
|
|
132
|
+
// Everything else about a story — its decisions, its rounds, its plan — is the panel in the list
|
|
133
|
+
// and not a note, and a post-it that tried to carry it would stop being a post-it. They are all
|
|
134
|
+
// drawn only when the server says there is a backlog: with it off the note is the note it has
|
|
135
|
+
// always been, down to the last element.
|
|
136
|
+
|
|
137
|
+
/** The story's name — `K42`, allocated once and never reused, never reassigned, never renamed. */
|
|
138
|
+
const keyMark = (story) => `<span class="key" title="${esc(story.key)}">${esc(story.key)}</span>`
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* An epic's colour. Nothing stores one and nothing should: a colour somebody had to choose is one
|
|
142
|
+
* more thing to choose, and it would then have to be kept in step with an epic that can be
|
|
143
|
+
* renamed and moved. The key is a number that never changes, so the hue is worked out from it —
|
|
144
|
+
* by the golden angle, which is what keeps K7 and K8 far apart instead of two shades of one
|
|
145
|
+
* green. Two repositories both have a K7 and both get the same colour; on a board where their
|
|
146
|
+
* notes stand side by side the label says which, and the colour was never the thing that did.
|
|
147
|
+
*/
|
|
148
|
+
const epicHue = (key) => Math.round((Number(String(key).replace(/\D/g, '') || 0) * 137.508) % 360)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The epic, in its colour, and the way into it: click it and the board becomes that epic.
|
|
152
|
+
*
|
|
153
|
+
* Inside that epic it is not drawn at all. Every note in a lane belongs to the same one, so the
|
|
154
|
+
* label would be the same pill repeated down the column saying what the strip at the top already
|
|
155
|
+
* says — and it would be a way in to where you are standing. The foot is the quietest part of the
|
|
156
|
+
* note and it has room for three marks, not for one of them said twice.
|
|
157
|
+
*/
|
|
158
|
+
function epicChip(story) {
|
|
159
|
+
if (!story.epic_key) return ''
|
|
160
|
+
if (laneEpic && laneEpic.key === story.epic_key && laneEpic.path === story.project_path) return ''
|
|
161
|
+
const label = `${story.epic_key} ${story.epic_title ?? ''}`.trim()
|
|
162
|
+
return `<button class="epic" style="--h:${epicHue(story.epic_key)}" title="${esc(
|
|
163
|
+
`${label} — click to see only this epic`
|
|
164
|
+
)}">${esc(label)}</button>`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The one thing to do with this story next, and it is the server that decided it — see `nextStep`
|
|
169
|
+
* in `server/backlog.js`. The note draws the answer and never has one of its own: these are the
|
|
170
|
+
* same rules `/k0-next` reasons over, and a second copy of them in here would be a second answer to
|
|
171
|
+
* one question from the day somebody edited one of the two.
|
|
172
|
+
*
|
|
173
|
+
* One suggestion per note, not a row of them. It goes first, ahead of Start and Done, because it is
|
|
174
|
+
* the thing you are being told to do. It answers whether it drew anything, because that is what
|
|
175
|
+
* decides how loud Start is allowed to be next to it: `null` is an answer here — a story that came
|
|
176
|
+
* through its counter-check clean is waiting for a person to press Done, and filling that in with a
|
|
177
|
+
* command would be inventing work.
|
|
178
|
+
*
|
|
179
|
+
* The one exception is `Ultracode`, drawn beside `Work` and only there. It is not a second
|
|
180
|
+
* suggestion — the server suggests one thing and this is not it — but the same suggestion taken a
|
|
181
|
+
* different way: the work of that story done by a manager with agents under it rather than by hand
|
|
182
|
+
* in one session. That is why it is a link and not a button, and why it exists nowhere else on the
|
|
183
|
+
* note: two roads out of one state are a choice, three would be a menu.
|
|
184
|
+
*/
|
|
185
|
+
function nextStepButton(story, btn) {
|
|
186
|
+
const step = story.next_step
|
|
187
|
+
if (!step || !(step.action || step.command)) return false
|
|
188
|
+
btn(step.label, () => doNextStep(story), step.why)
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
81
191
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Doing it, wherever it was pressed — the note or a row in the list.
|
|
194
|
+
*
|
|
195
|
+
* The two things a suggestion can be: pick a closed conversation back up, or start a session
|
|
196
|
+
* saying `/k0-plan K42`. A step that is neither is a step from a server newer than this page, and
|
|
197
|
+
* doing nothing is the only honest answer left — guessing would put something on a command line.
|
|
198
|
+
*
|
|
199
|
+
* `/k0-work` asks the same question Quick Start asks, because it is the same act: it is the one
|
|
200
|
+
* command here that goes and does the work, and doing it on a story that waits on something
|
|
201
|
+
* unfinished is the moment the note you left yourself was for. The other commands only talk about
|
|
202
|
+
* the story, and being asked before a discussion would be a question with nothing riding on it.
|
|
203
|
+
*/
|
|
204
|
+
async function doNextStep(story) {
|
|
205
|
+
const step = story.next_step
|
|
206
|
+
if (!step) return
|
|
207
|
+
if (step.action === 'resume') return start(story.id, 'resume')
|
|
208
|
+
if (!step.command) return
|
|
209
|
+
if (step.command === 'k0-work' && !(await mayStart(story))) return
|
|
210
|
+
startCommand(story, step.command)
|
|
211
|
+
}
|
|
85
212
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
213
|
+
/** Whether this story is at the one moment where the manager is offered: the same one `Work` is. */
|
|
214
|
+
const mayUltracode = (story) => story.next_step?.command === 'k0-work'
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The manager, on the story `Work` is being offered on. It asks the same question `Work` asks
|
|
218
|
+
* before it starts, because it is the same act done at a larger size: a story that waits on
|
|
219
|
+
* something unfinished is no better a thing to hand to five agents than to one.
|
|
220
|
+
*/
|
|
221
|
+
async function doUltracode(story) {
|
|
222
|
+
if (!(await mayStart(story))) return
|
|
223
|
+
startCommand(story, 'k0-ultracode')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** What a story is still waiting for. Only what is not done: a dependency that closed is history. */
|
|
227
|
+
const waitingOn = (story) => (story.deps ?? []).filter((d) => d.state !== 'Done')
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The mark that says this one waits on something else.
|
|
231
|
+
*
|
|
232
|
+
* It warns and it does not stop: nothing here refuses to start a blocked story, and the button
|
|
233
|
+
* that starts it asks once and then goes ahead. A backlog that will not let you work on what you
|
|
234
|
+
* want is a backlog you stop using — so this is a note to yourself, drawn the size of one.
|
|
235
|
+
*/
|
|
236
|
+
function depMark(story) {
|
|
237
|
+
const open = waitingOn(story)
|
|
238
|
+
if (!open.length) return ''
|
|
239
|
+
const list = open.map((d) => `${d.key} ${d.title} (${d.state})`).join(' · ')
|
|
240
|
+
return `<span class="waits" title="${esc(`Waiting on ${list}`)}">${ICON.dep}${
|
|
241
|
+
open.length > 1 ? open.length : ''
|
|
242
|
+
}</span>`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The flag, at the bottom right, mirroring the pencil at the top.
|
|
247
|
+
*
|
|
248
|
+
* It is a mark and not a target: it is put on from the pencil's dialog, where the rest of the
|
|
249
|
+
* decisions about a story are taken. An empty outline sitting on every note waiting to be clicked
|
|
250
|
+
* was a mark that was everywhere and therefore marked nothing.
|
|
251
|
+
*
|
|
252
|
+
* It is the target too, and it is the only place the flag is set: a checkbox in the dialog meant
|
|
253
|
+
* opening the dialog, ticking a box and saving, for a mark you put on and take off in passing. It
|
|
254
|
+
* is invisible until the pointer is on the note — an outline on every note waiting to be clicked
|
|
255
|
+
* is a mark that is everywhere and therefore marks nothing — and once it is on it stays on, in the
|
|
256
|
+
* warn colour, with the key and the name taking that colour with it. That is the half that does
|
|
257
|
+
* the work: the point of flagging something is to find it again from across the room, and a
|
|
258
|
+
* fifteen-pixel glyph cannot be seen from there.
|
|
259
|
+
*/
|
|
260
|
+
const flagMark = (story) => {
|
|
261
|
+
const on = !!story.starred
|
|
262
|
+
const why = on
|
|
263
|
+
? 'Flagged — click to take it off'
|
|
264
|
+
: 'Flag it: it stands out on the board, and comes first when k0 is asked what to do next'
|
|
265
|
+
return `<button type="button" class="flag${on ? ' on' : ''}" aria-pressed="${on}"
|
|
266
|
+
title="${esc(why)}" aria-label="${esc(why)}">${ICON.flag}</button>`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── The epic you are standing in ───────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function readLane() {
|
|
272
|
+
try {
|
|
273
|
+
const v = JSON.parse(localStorage.getItem('k0-epic') || 'null')
|
|
274
|
+
return v && v.key && v.path ? { key: v.key, path: v.path } : null
|
|
275
|
+
} catch {
|
|
276
|
+
return null // a lane that cannot be read is no lane: the whole board is the safe answer
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Go into an epic, or come back out of it with null. */
|
|
281
|
+
function setLane(lane) {
|
|
282
|
+
laneEpic = lane
|
|
283
|
+
if (lane) localStorage.setItem('k0-epic', JSON.stringify(lane))
|
|
284
|
+
else localStorage.removeItem('k0-epic')
|
|
285
|
+
lastSignature = ''
|
|
286
|
+
refresh()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const enterEpic = (story) => setLane({ key: story.epic_key, path: story.project_path })
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The epic the board is inside, as the server describes it — the title and the progress are its
|
|
293
|
+
* to say, not something worked out here from the notes that happen to be on screen.
|
|
294
|
+
*
|
|
295
|
+
* An epic that is not in the answer any more has been deleted, or its repository has left this
|
|
296
|
+
* machine. Then the way out is taken for you: standing in a lane that no longer exists is an
|
|
297
|
+
* empty board with nothing on it to explain itself.
|
|
298
|
+
*/
|
|
299
|
+
function currentEpic(data) {
|
|
300
|
+
if (!backlogOn || !laneEpic) return null
|
|
301
|
+
const found = (data.epics ?? []).find((e) => e.key === laneEpic.key && e.project_path === laneEpic.path)
|
|
302
|
+
if (!found) {
|
|
303
|
+
laneEpic = null
|
|
304
|
+
localStorage.removeItem('k0-epic')
|
|
305
|
+
}
|
|
306
|
+
return found ?? null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function postit(story, now) {
|
|
310
|
+
const el = document.createElement('article')
|
|
311
|
+
el.className = `postit ${story.status}${backlogOn && story.starred ? ' flagged' : ''}`
|
|
312
|
+
el.dataset.id = story.id
|
|
313
|
+
el.style.transform = `rotate(${tilt(story.id)}deg)`
|
|
314
|
+
|
|
315
|
+
const alive = story.session_id && story.session_alive && !story.completed_at
|
|
316
|
+
const dead = story.session_id && !story.session_alive && !story.completed_at
|
|
317
|
+
el.title = alive
|
|
318
|
+
? 'Double-click to bring its terminal up front'
|
|
319
|
+
: story.completed_at
|
|
320
|
+
? ''
|
|
321
|
+
: story.session_id
|
|
322
|
+
? 'Double-click to pick the conversation up where it was'
|
|
323
|
+
: 'Double-click to open a session on it'
|
|
324
|
+
|
|
325
|
+
// On a finished story there is nothing left to edit: the bin takes the pencil's place.
|
|
326
|
+
const corner = story.completed_at
|
|
327
|
+
? { icon: ICON.del, label: 'Delete', cls: 'corner del', act: () => remove(story) }
|
|
328
|
+
: { icon: ICON.edit, label: 'Edit', cls: 'corner', act: () => openEditor(story) }
|
|
90
329
|
|
|
91
330
|
el.innerHTML = `
|
|
92
331
|
<button class="${corner.cls}" title="${corner.label}" aria-label="${corner.label}">${corner.icon}</button>
|
|
93
|
-
<div class="head"
|
|
94
|
-
|
|
332
|
+
<div class="head">${backlogOn ? keyMark(story) : ''}<span class="tag">${esc(story.project_name)}</span>${gitChip(
|
|
333
|
+
story.git,
|
|
95
334
|
// A session with a worktree of its own also has files of its own: that is the directory
|
|
96
335
|
// to open. But only while the worktree really exists — `own` comes from the server,
|
|
97
336
|
// which has just read that directory's git state.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)}${loadChip(
|
|
101
|
-
<div class="title">${esc(
|
|
337
|
+
story.git?.own ? story.work_path : story.project_path,
|
|
338
|
+
story.id
|
|
339
|
+
)}${loadChip(story)}</div>
|
|
340
|
+
<div class="title">${esc(story.title)}</div>
|
|
102
341
|
${
|
|
103
342
|
// Description and prompt travel together in one box, and that box is the only part of the
|
|
104
|
-
//
|
|
343
|
+
// story allowed to shrink: whatever the text does, the repository, the title, the age and
|
|
105
344
|
// the buttons stay where they are.
|
|
106
|
-
|
|
345
|
+
story.description || story.prompt
|
|
107
346
|
? `<div class="body">
|
|
108
|
-
${
|
|
109
|
-
${
|
|
347
|
+
${story.description ? `<div class="desc">${esc(story.description)}</div>` : ''}
|
|
348
|
+
${story.prompt ? `<div class="prompt">${esc(story.prompt)}</div>` : ''}
|
|
110
349
|
</div>`
|
|
111
350
|
: ''
|
|
112
351
|
}
|
|
113
352
|
<div class="foot">
|
|
353
|
+
${
|
|
354
|
+
// The epic and what the story is waiting for: two marks on the line that was already there,
|
|
355
|
+
// rather than a row of their own. The flag is not among them — it is hung in the corner,
|
|
356
|
+
// where it can be seen without reading the note.
|
|
357
|
+
backlogOn ? `${epicChip(story)}${depMark(story)}` : ''
|
|
358
|
+
}
|
|
114
359
|
${
|
|
115
360
|
// Whoever shut the window, said in the same breath and the same weight — this line is
|
|
116
361
|
// already only an italic, and it stays one. Which of the two it is does not need its own
|
|
117
362
|
// place in the redraw signature: `auto_closed` never moves without `session_alive`
|
|
118
363
|
// moving with it, and that one is in there.
|
|
119
|
-
dead ? `<span class="dead">${
|
|
364
|
+
dead ? `<span class="dead">${story.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
|
|
120
365
|
}
|
|
121
|
-
<span class="since" title="${LABEL[
|
|
366
|
+
<span class="since" title="${LABEL[story.status]} for ${since(story.status_since, now)}">${since(story.status_since, now)}</span>
|
|
122
367
|
</div>
|
|
123
|
-
<div class="actions"></div
|
|
368
|
+
<div class="actions"></div>
|
|
369
|
+
${backlogOn ? flagMark(story) : ''}`
|
|
124
370
|
|
|
125
|
-
// The only way to open the editor: clicking the
|
|
371
|
+
// The only way to open the editor: clicking the story itself no longer does anything.
|
|
126
372
|
el.querySelector('.corner').onclick = (e) => {
|
|
127
373
|
e.stopPropagation()
|
|
128
374
|
corner.act()
|
|
129
375
|
}
|
|
130
376
|
|
|
377
|
+
// The one mark in the foot that does something. `stopPropagation` for the same reason the corner
|
|
378
|
+
// has it: the note answers a double click by bringing its terminal up, and this is not a way of
|
|
379
|
+
// asking for that.
|
|
380
|
+
const chip = el.querySelector('.epic')
|
|
381
|
+
if (chip)
|
|
382
|
+
chip.onclick = (e) => {
|
|
383
|
+
e.stopPropagation()
|
|
384
|
+
enterEpic(story)
|
|
385
|
+
}
|
|
386
|
+
|
|
131
387
|
const actions = el.querySelector('.actions')
|
|
132
388
|
const btn = (text, fn, title, cls) => {
|
|
133
389
|
const b = document.createElement('button')
|
|
@@ -141,41 +397,81 @@ function postit(card, now) {
|
|
|
141
397
|
actions.append(b)
|
|
142
398
|
}
|
|
143
399
|
|
|
144
|
-
if (
|
|
145
|
-
btn('Reopen', () => setCompleted(
|
|
146
|
-
} else if (!
|
|
147
|
-
|
|
400
|
+
if (story.completed_at) {
|
|
401
|
+
btn('Reopen', () => setCompleted(story.id, false))
|
|
402
|
+
} else if (!story.session_id) {
|
|
403
|
+
// What to do next comes first, and where there is one Quick Start goes to the end of the row as
|
|
404
|
+
// a link — the same place Close takes for the same reason. They are the two roads out and they
|
|
405
|
+
// are not the same road: the suggestion opens a session on `/k0-discuss K42` and takes the long
|
|
406
|
+
// way round, Quick Start opens an empty one and leaves the typing to you. Where there is no
|
|
407
|
+
// suggestion Quick Start is the button it has always been.
|
|
408
|
+
const suggested = backlogOn && nextStepButton(story, btn)
|
|
409
|
+
if (!suggested) btn('Quick Start', () => startStory(story))
|
|
410
|
+
// The manager, next to `Work` and only there: the same story, handed to several agents at once
|
|
411
|
+
// instead of to one session. A link rather than a button because `Work` is still the ordinary
|
|
412
|
+
// road, and this is the one you take on purpose.
|
|
413
|
+
if (suggested && mayUltracode(story))
|
|
414
|
+
btn('Ultracode', () => doUltracode(story), 'hand it to a manager with agents under it', 'link')
|
|
415
|
+
// And Done beside it, once there is a backlog behind the board. A story with no session is not
|
|
416
|
+
// only an idea nobody has touched: it is also the ordinary shape of one planned here and then
|
|
417
|
+
// worked on in a terminal the user opened himself, or checked over by hand. `Done` is the only
|
|
418
|
+
// thing in k0 that closes a story, so without this there is no way to close that one at all —
|
|
419
|
+
// and the backlog's own rule is that any state may follow any other, Backlog to Done included.
|
|
420
|
+
// Switched off, the post-it is exactly the post-it it has always been.
|
|
421
|
+
if (backlogOn) btn('Done', () => setCompleted(story.id, true), 'close this job')
|
|
422
|
+
if (suggested) btn('Quick Start', () => startStory(story), 'open a session on it and type there yourself', 'link')
|
|
148
423
|
} else {
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
424
|
+
// A session that is running needs no button at all: the terminal is where the work is, and a
|
|
425
|
+
// double click on the note goes there. One that has ended is a conversation waiting to be
|
|
426
|
+
// picked up, and that is the suggestion the server gives back — so with a backlog behind the
|
|
427
|
+
// board Resume arrives as the suggestion, and the line under it is only for a board with the
|
|
428
|
+
// backlog switched off, where nothing suggests anything.
|
|
429
|
+
const suggested = backlogOn && nextStepButton(story, btn)
|
|
430
|
+
if (dead && !suggested) btn('Resume', () => start(story.id, 'resume'))
|
|
431
|
+
btn('Done', () => setCompleted(story.id, true), 'close this job and its terminal')
|
|
152
432
|
// Close gives the memory back without declaring the work over, and it comes after Done, as a
|
|
153
433
|
// link rather than a button: it is the rarer of the two and should not compete with it. Not
|
|
154
434
|
// while the session is working — stopping one mid-thought is not memory saved, it is work lost.
|
|
155
|
-
if (!dead && !BUSY.has(
|
|
435
|
+
if (!dead && !BUSY.has(story.status))
|
|
156
436
|
btn(
|
|
157
437
|
'Close',
|
|
158
|
-
() => closeSession(
|
|
159
|
-
'stop the session and its terminal, and leave the
|
|
438
|
+
() => closeSession(story.id),
|
|
439
|
+
'stop the session and its terminal, and leave the story where it is',
|
|
160
440
|
'link'
|
|
161
441
|
)
|
|
162
442
|
}
|
|
163
443
|
|
|
444
|
+
// The flag: pressed here and nowhere else. `stopPropagation` for the reason the corner and the
|
|
445
|
+
// epic chip have it — the note answers a click of its own, and this is not a way of asking for
|
|
446
|
+
// that.
|
|
447
|
+
const flag = el.querySelector('.flag')
|
|
448
|
+
if (flag) {
|
|
449
|
+
flag.onclick = (e) => {
|
|
450
|
+
e.stopPropagation()
|
|
451
|
+
setFlag(story)
|
|
452
|
+
}
|
|
453
|
+
flag.addEventListener('dblclick', (e) => e.stopPropagation())
|
|
454
|
+
}
|
|
455
|
+
|
|
164
456
|
// The git mark is a link: a double click on it would open two tabs and bring the terminal to
|
|
165
457
|
// the front into the bargain. Double clicks do not reach it.
|
|
166
458
|
el.querySelector('.git')?.addEventListener('dblclick', (e) => e.stopPropagation())
|
|
167
459
|
|
|
168
|
-
// Double click:
|
|
460
|
+
// Double click: into the work, whatever state the note is in. Its terminal if one is running,
|
|
461
|
+
// the conversation it left behind if there is one, and a fresh session if there is neither — a
|
|
462
|
+
// gesture that does nothing on half the notes is a gesture nobody ends up trusting.
|
|
169
463
|
el.ondblclick = (e) => {
|
|
170
|
-
if (
|
|
464
|
+
if (story.completed_at) return
|
|
171
465
|
e.preventDefault()
|
|
172
|
-
focusTerminal(
|
|
466
|
+
if (alive) return focusTerminal(story.id)
|
|
467
|
+
if (story.session_id) return start(story.id, 'resume')
|
|
468
|
+
startStory(story)
|
|
173
469
|
}
|
|
174
470
|
return el
|
|
175
471
|
}
|
|
176
472
|
|
|
177
473
|
// Few icons, all in the same stroke: the pencil opens the editor, the bin throws away, the
|
|
178
|
-
// "+" gives birth to a
|
|
474
|
+
// "+" gives birth to a story. The same "+" as the one in the bar at the top.
|
|
179
475
|
const ICON = {
|
|
180
476
|
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
|
|
181
477
|
del: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
|
|
@@ -185,16 +481,22 @@ const ICON = {
|
|
|
185
481
|
// The dev server: a world, because what is behind it is a site. A meridian and a parallel are
|
|
186
482
|
// enough to read it at twelve pixels — more lines and it goes back to being a circle.
|
|
187
483
|
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>',
|
|
484
|
+
// Waiting on something else: a link of a chain, because that is what a dependency is. Broken in
|
|
485
|
+
// the middle, because it is the half that has not arrived that the mark is about.
|
|
486
|
+
dep: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 14l4-4"/><path d="M13.5 7.5l1-1a3.5 3.5 0 1 1 5 5l-1 1"/><path d="M10.5 16.5l-1 1a3.5 3.5 0 1 1-5-5l1-1"/></svg>',
|
|
487
|
+
// Flagged. Filled, and drawn once — there is no empty flag, because the mark is only ever on a
|
|
488
|
+
// note that has one: an outline on every other note is a mark that says nothing.
|
|
489
|
+
flag: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 21V4"/><path d="M6 4.5h11.5l-2.4 3.8 2.4 3.7H6" fill="currentColor"/></svg>',
|
|
188
490
|
}
|
|
189
491
|
|
|
190
492
|
const esc = (s) =>
|
|
191
493
|
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
192
494
|
|
|
193
495
|
// ── The git mark ───────────────────────────────────────────────────
|
|
194
|
-
// One symbol, on the
|
|
496
|
+
// One symbol, on the story and above the column: what is needed is to see at a glance whether
|
|
195
497
|
// anything is still outstanding. The rest — which branch, how many files, how many commits, and
|
|
196
498
|
// how many of those commits belong to this session — is read by hovering over it, the way the
|
|
197
|
-
// time at the bottom of a
|
|
499
|
+
// time at the bottom of a story already works.
|
|
198
500
|
const GIT_ICON = {
|
|
199
501
|
// Things that are nowhere yet: the filled dot editors use.
|
|
200
502
|
dirty: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5.5"/></svg>',
|
|
@@ -223,11 +525,11 @@ function gitDetail(g) {
|
|
|
223
525
|
/**
|
|
224
526
|
* The git mark is also the door to the files: clicking it opens the viewer in another tab, and
|
|
225
527
|
* the board stays where it is. Where there is no mark — a repository without git, or git that
|
|
226
|
-
* has not answered yet — a lens goes in its place, so every
|
|
528
|
+
* has not answered yet — a lens goes in its place, so every story and every column still leads
|
|
227
529
|
* in.
|
|
228
530
|
*/
|
|
229
|
-
function gitChip(g, repo,
|
|
230
|
-
const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${
|
|
531
|
+
function gitChip(g, repo, story) {
|
|
532
|
+
const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${story ? `&story=${story}` : ''}`)
|
|
231
533
|
const open = 'click to see the files'
|
|
232
534
|
if (!g) return `<a class="git none" href="${to}" target="_blank" title="${open}">${GIT_ICON.files}</a>`
|
|
233
535
|
const kind = g.dirty > 0 ? 'dirty' : g.unpushed > 0 ? 'push' : 'ok'
|
|
@@ -332,15 +634,15 @@ const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}`
|
|
|
332
634
|
* what is doing the eating in there — is read by hovering, and it is the answer to the real
|
|
333
635
|
* question: why does this terminal weigh so much.
|
|
334
636
|
*/
|
|
335
|
-
function loadChip(
|
|
336
|
-
if (!showLoad || !
|
|
337
|
-
const l =
|
|
637
|
+
function loadChip(story) {
|
|
638
|
+
if (!showLoad || !story.load) return ''
|
|
639
|
+
const l = story.load
|
|
338
640
|
const bits = l.top.map((t) => `${t.name} ${Math.round(t.rss / 1048576)} MB`)
|
|
339
641
|
if (l.cpu !== null) bits.push(`${Math.round(l.cpu * 100)}% CPU`)
|
|
340
642
|
bits.push(`${l.procs} process${l.procs === 1 ? '' : 'es'}`)
|
|
341
643
|
// Red on one only, and only while the machine is struggling: if they are all red they stop
|
|
342
644
|
// saying anything, and it is not true that they all need closing.
|
|
343
|
-
const hot = machine?.pressure !== 'ok' &&
|
|
645
|
+
const hot = machine?.pressure !== 'ok' && story.id === heaviest
|
|
344
646
|
return `<span class="load${hot ? ' hot' : ''}" title="${esc(bits.join(' · '))}">${weight(l.rss)}</span>`
|
|
345
647
|
}
|
|
346
648
|
|
|
@@ -348,34 +650,81 @@ function loadChip(card) {
|
|
|
348
650
|
const gitSig = (g) => (g ? `${g.dirty}/${g.unpushed}/${g.own ? 1 : 0}/${g.mine ?? ''}` : '')
|
|
349
651
|
|
|
350
652
|
/**
|
|
351
|
-
* How long this
|
|
653
|
+
* How long this story has been where it is. Until it has ever changed status there is nothing in
|
|
352
654
|
* `session_event` and `status_since` is empty: then it counts from when it came into existence,
|
|
353
|
-
* otherwise a
|
|
655
|
+
* otherwise a story just created would end up at the bottom of its group.
|
|
354
656
|
*/
|
|
355
657
|
const freshness = (c) => c.status_since ?? c.created_at ?? 0
|
|
356
658
|
|
|
357
659
|
/**
|
|
358
660
|
* The order of attention: most urgent status first, and among equal statuses whichever entered
|
|
359
|
-
* it last. It holds between the
|
|
661
|
+
* it last. It holds between the stories inside a column and — applied to the leading story —
|
|
360
662
|
* between the columns on the board.
|
|
361
663
|
*/
|
|
362
664
|
const byAttention = (a, b) =>
|
|
363
665
|
ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || freshness(b) - freshness(a)
|
|
364
666
|
|
|
365
667
|
function render(data) {
|
|
366
|
-
const visible = data.cards.filter((c) => !hidden.has(c.status))
|
|
367
668
|
const board = $('#board')
|
|
368
|
-
board.textContent = ''
|
|
369
669
|
|
|
370
|
-
|
|
670
|
+
// The list is a page that scrolls; the board is a surface that moves under a fixed window. The
|
|
671
|
+
// class is what takes the one out of the other — see list.css — and it goes on before anything is
|
|
672
|
+
// drawn, so nothing is ever measured in the wrong world.
|
|
673
|
+
const asList = byList()
|
|
674
|
+
// Emptying the board is how the kanban is redrawn, and in list mode it is exactly what must not
|
|
675
|
+
// happen: the list keeps its two halves between redraws (see `drawList`), and an element taken
|
|
676
|
+
// out of the document comes back scrolled to the top — which on a poll that runs once a second
|
|
677
|
+
// is the panel losing the reader's place while they are reading it.
|
|
678
|
+
if (!asList) board.textContent = ''
|
|
679
|
+
board.classList.toggle('list', asList)
|
|
680
|
+
$('#viewport').classList.toggle('list', asList)
|
|
681
|
+
if (!asList) {
|
|
682
|
+
board.classList.remove('open')
|
|
683
|
+
leaveList()
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Inside an epic, the board is that epic. Everything below works on `pool` rather than on
|
|
687
|
+
// `data.stories`, so the epic is a lens over the same board and not a second one: the filters
|
|
688
|
+
// still filter, the columns still fold, the notes are the same notes.
|
|
689
|
+
const inEpic = currentEpic(data)
|
|
690
|
+
drawLane(inEpic)
|
|
691
|
+
renderRepoFilter(data, inEpic)
|
|
692
|
+
// The three things that decide what is in scope, applied once and before the fork: the epic lane,
|
|
693
|
+
// the repository, and the status pills. Both views work on what comes out, which is what makes
|
|
694
|
+
// switching between them a change of shape and never a change of what you are looking at.
|
|
695
|
+
const only = inEpic ? '' : repoFilter
|
|
696
|
+
const pool = data.stories.filter(
|
|
697
|
+
(c) =>
|
|
698
|
+
(!inEpic || (c.epic_key === inEpic.key && c.project_path === inEpic.project_path)) &&
|
|
699
|
+
(!only || c.project_path === only)
|
|
700
|
+
)
|
|
701
|
+
const visible = pool.filter((c) => !hidden.has(c.status))
|
|
702
|
+
|
|
703
|
+
if (asList) {
|
|
704
|
+
// The epics go through the same two narrowings the stories did. They are not filtered by status
|
|
705
|
+
// — an epic has none — but one belonging to a repository you have filtered away has no business
|
|
706
|
+
// heading a group of nothing.
|
|
707
|
+
const epics = (data.epics ?? []).filter(
|
|
708
|
+
(e) =>
|
|
709
|
+
(!inEpic || (e.key === inEpic.key && e.project_path === inEpic.project_path)) &&
|
|
710
|
+
(!only || e.project_path === only)
|
|
711
|
+
)
|
|
712
|
+
drawList(board, { stories: visible, all: data.stories, epics }, LIST)
|
|
713
|
+
// No message in the middle here: the list says for itself when there is nothing in it, and it
|
|
714
|
+
// says it where the rows would have been rather than across the whole window.
|
|
715
|
+
showEmpty(false, inEpic)
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const columns = (inEpic ? data.columns.filter((c) => c.path === inEpic.project_path) : data.columns)
|
|
371
720
|
.map((col) => ({
|
|
372
721
|
col,
|
|
373
|
-
|
|
722
|
+
stories: visible.filter((c) => c.project_path === col.path).sort(byAttention),
|
|
374
723
|
}))
|
|
375
|
-
.filter(({
|
|
724
|
+
.filter(({ stories }) => stories.length)
|
|
376
725
|
|
|
377
726
|
// In driving mode the columns queue up by attention too: a column is worth as much as its
|
|
378
|
-
// leading
|
|
727
|
+
// leading story, and since the stories are sorted already, that first one is necessarily the most
|
|
379
728
|
// urgent it has. One red is enough to bring it left; when that red moves into working, the
|
|
380
729
|
// column slides right by itself. On a tie the alphabetical order from the server stands,
|
|
381
730
|
// because `sort` is stable.
|
|
@@ -385,32 +734,44 @@ function render(data) {
|
|
|
385
734
|
// they are all in front of you already: there is nothing to bring to anybody, and a board that
|
|
386
735
|
// reshuffles itself while you work only loses your place. With the switch off the columns stand
|
|
387
736
|
// still in alphabetical order — the server's, which is not touched here.
|
|
388
|
-
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.
|
|
737
|
+
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.stories[0], b.stories[0]))
|
|
389
738
|
|
|
390
739
|
// Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
|
|
391
740
|
// four rules and for why the day is counted back from your last piece of work rather than from
|
|
392
741
|
// the clock.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
742
|
+
//
|
|
743
|
+
// Inside an epic none of that applies: you went in to look at one thing, and a lane that put
|
|
744
|
+
// half of itself away would be answering a question you did not ask. Nothing is folded, and
|
|
745
|
+
// nothing is remembered either — what you do in a lane must not decide what the board looks
|
|
746
|
+
// like when you come back out of it.
|
|
747
|
+
//
|
|
748
|
+
// The repository filter is the same answer twice over. You asked for one repository; folding it
|
|
749
|
+
// away for having been quiet would leave a board with nothing on it and the reason hidden inside
|
|
750
|
+
// a dropdown, and the way back would be through the `Old` column that is not drawn either.
|
|
751
|
+
const { open, alive } = inEpic || only
|
|
752
|
+
? { open: columns.map(({ col }) => col.path), alive: new Set() }
|
|
753
|
+
: split({
|
|
754
|
+
paths: columns.map((c) => c.col.path),
|
|
755
|
+
stories: pool,
|
|
756
|
+
folded,
|
|
757
|
+
held,
|
|
758
|
+
})
|
|
399
759
|
// A column open now is open for the rest of the visit, and a column that is open is not one
|
|
400
760
|
// you have put away: a session woke up in there, or you went and fetched it back yourself.
|
|
401
761
|
let forget = false
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
762
|
+
if (!inEpic && !only)
|
|
763
|
+
for (const p of open) {
|
|
764
|
+
held.add(p)
|
|
765
|
+
if (folded.delete(p)) forget = true
|
|
766
|
+
}
|
|
406
767
|
if (forget) saveFolded()
|
|
407
768
|
|
|
408
769
|
const openSet = new Set(open)
|
|
409
|
-
for (const { col,
|
|
770
|
+
for (const { col, stories } of columns.filter(({ col }) => openSet.has(col.path))) {
|
|
410
771
|
const wrap = document.createElement('section')
|
|
411
772
|
wrap.className = 'column'
|
|
412
|
-
// The "+" next to the name: a new
|
|
413
|
-
const add = `New
|
|
773
|
+
// The "+" next to the name: a new story on THIS repository, without picking it.
|
|
774
|
+
const add = `New story in ${esc(col.name)}`
|
|
414
775
|
// And next to it, the one that puts the column away without waiting for it to go quiet on
|
|
415
776
|
// its own. It sits there in plain sight next to the `+`, because a button you have to go
|
|
416
777
|
// hunting for with the pointer is a button nobody knows exists.
|
|
@@ -421,11 +782,16 @@ function render(data) {
|
|
|
421
782
|
// blinking in and out — and it would have done it on exactly the columns you are working on.
|
|
422
783
|
// A control that stays where it was and says why it will not move is worth more than one
|
|
423
784
|
// that is only there when it agrees with you. The title carries the reason.
|
|
785
|
+
//
|
|
786
|
+
// In a lane there is no fold at all. There is one column and it is the epic: putting it away
|
|
787
|
+
// would leave an empty board with a strip at the top saying which epic it is empty of.
|
|
424
788
|
const live = alive.has(col.path)
|
|
425
789
|
const away = live ? `${col.name} has something going on: it cannot be put away` : `Put ${col.name} away`
|
|
426
|
-
const fold =
|
|
427
|
-
|
|
428
|
-
|
|
790
|
+
const fold = inEpic
|
|
791
|
+
? ''
|
|
792
|
+
: `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
|
|
793
|
+
live ? ' disabled' : ''
|
|
794
|
+
}>${ICON.fold}</button>`
|
|
429
795
|
// Two groups and a name between them: what you press on the left, what tells you how things
|
|
430
796
|
// stand on the right. The count of post-its that used to sit after the name is gone — it
|
|
431
797
|
// took up the room the name now uses, and it never answered a question anybody had.
|
|
@@ -441,19 +807,138 @@ function render(data) {
|
|
|
441
807
|
ICON.plus
|
|
442
808
|
}</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
|
|
443
809
|
wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
|
|
444
|
-
wrap.querySelector('.fold')
|
|
810
|
+
const folder = wrap.querySelector('.fold')
|
|
811
|
+
if (folder) folder.onclick = () => putAway(col.path)
|
|
445
812
|
const globe = wrap.querySelector('.globe')
|
|
446
813
|
if (globe) wireGlobe(globe, col.path, col.server)
|
|
447
|
-
for (const c of
|
|
814
|
+
for (const c of stories) wrap.append(postit(c, data.now))
|
|
448
815
|
board.append(wrap)
|
|
449
816
|
}
|
|
450
817
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
818
|
+
// The two columns on the right are about the repositories that are NOT in front of you, which is
|
|
819
|
+
// a question a lane has already answered — there is one repository in here, and it is this one —
|
|
820
|
+
// and which the repository filter answers the same way.
|
|
821
|
+
if (!inEpic && !only) {
|
|
822
|
+
board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
|
|
823
|
+
board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
|
|
824
|
+
}
|
|
825
|
+
showEmpty(columns.length === 0, inEpic, only)
|
|
454
826
|
refit() // the board changed size: the view comes back inside its limits
|
|
455
827
|
}
|
|
456
828
|
|
|
829
|
+
/**
|
|
830
|
+
* The line in the middle of an empty board, and what it should say.
|
|
831
|
+
*
|
|
832
|
+
* "Add your first story" is right on a board with nothing on it and wrong everywhere else: inside
|
|
833
|
+
* an epic it is not the dashboard that is empty, and behind a repository filter it is not the
|
|
834
|
+
* dashboard that is being looked at. A message that answers the wrong question sends you looking
|
|
835
|
+
* for a fault that is not there.
|
|
836
|
+
*/
|
|
837
|
+
function showEmpty(empty, inEpic, only = '') {
|
|
838
|
+
const el = $('#empty')
|
|
839
|
+
el.style.display = empty ? 'grid' : 'none'
|
|
840
|
+
if (!empty) return
|
|
841
|
+
if (inEpic) el.textContent = `Nothing in ${inEpic.key} yet — or the filters have put it all away.`
|
|
842
|
+
else if (only) el.textContent = `Nothing in ${nameOf(only)} — or the filters have put it all away.`
|
|
843
|
+
else el.textContent = 'The dashboard is empty. Add your first story.'
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── The epic you are standing in ───────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The strip under the top bar: which epic, how far it has got, and the way out.
|
|
850
|
+
*
|
|
851
|
+
* The progress is the server's arithmetic and not this page's — done over total, no weighting,
|
|
852
|
+
* counted over the stories of the epic and not over the notes that happen to be on screen. Half
|
|
853
|
+
* of them can be hidden by a filter; that does not make the epic half finished.
|
|
854
|
+
*/
|
|
855
|
+
function drawLane(epic) {
|
|
856
|
+
const bar = $('#epic-lane')
|
|
857
|
+
bar.hidden = !epic
|
|
858
|
+
if (!epic) return
|
|
859
|
+
const { done, total } = epic.progress
|
|
860
|
+
bar.style.setProperty('--h', epicHue(epic.key))
|
|
861
|
+
$('#epic-key').textContent = epic.key
|
|
862
|
+
$('#epic-name').textContent = epic.title
|
|
863
|
+
$('#epic-repo').textContent = epic.project_name
|
|
864
|
+
$('#epic-fill').style.width = `${total ? Math.round((done / total) * 100) : 0}%`
|
|
865
|
+
$('#epic-count').textContent = `${done} of ${total} done`
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Everything the repository menu can offer, newest board first. `''` is all of them. */
|
|
869
|
+
let repoOptions = [['', 'All repositories']]
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Which repository both views are showing, when they are showing one.
|
|
873
|
+
*
|
|
874
|
+
* Only the list is kept here; drawing it is `paintRepoList`, which happens when the menu is opened
|
|
875
|
+
* and not on every redraw of the board. The board is asked for once a second, and rebuilding rows
|
|
876
|
+
* under an open menu is how a menu closes itself out from under the pointer.
|
|
877
|
+
*/
|
|
878
|
+
function renderRepoFilter(data, inEpic) {
|
|
879
|
+
// Whether it is on the bar at all belongs to `renderColumnsSwitch`, which is the only place that
|
|
880
|
+
// knows all the answers. Here there is only what is in it.
|
|
881
|
+
if (inEpic) return
|
|
882
|
+
// A repository that has no stories left has no column and cannot be filtered to: keeping it
|
|
883
|
+
// would leave the board empty with the reason hidden inside a menu nobody has opened.
|
|
884
|
+
if (repoFilter && !data.columns.some((c) => c.path === repoFilter)) chooseRepo('')
|
|
885
|
+
repoOptions = [['', 'All repositories'], ...data.columns.map((c) => [c.path, c.name])]
|
|
886
|
+
$('#repo-filter-btn').textContent = repoOptions.find(([v]) => v === repoFilter)?.[1] ?? 'All repositories'
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The rows of the repository menu, narrowed by what has been typed into it. */
|
|
890
|
+
function paintRepoList(query = '') {
|
|
891
|
+
const rows = fuzzy(repoOptions, query, ([, label]) => label)
|
|
892
|
+
$('#repo-filter-rows').innerHTML = rows.length
|
|
893
|
+
? rows
|
|
894
|
+
.map(([v, label]) => `<div role="option" data-repo="${esc(v)}" class="${v === repoFilter ? 'on' : ''}"
|
|
895
|
+
aria-selected="${v === repoFilter}">${esc(label)}</div>`)
|
|
896
|
+
.join('')
|
|
897
|
+
: '<div style="color:var(--muted)">no repository with that name</div>'
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Open or shut, in one place: the arrow on the button and the list itself always agree. */
|
|
901
|
+
function showRepoList(open) {
|
|
902
|
+
$('#repo-filter-list').hidden = !open
|
|
903
|
+
$('#repo-filter-btn').setAttribute('aria-expanded', String(open))
|
|
904
|
+
if (!open) return
|
|
905
|
+
$('#repo-filter-q').value = ''
|
|
906
|
+
paintRepoList('')
|
|
907
|
+
$('#repo-filter-q').focus()
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function chooseRepo(path) {
|
|
911
|
+
repoFilter = path
|
|
912
|
+
if (path) localStorage.setItem('k0-repo-filter', path)
|
|
913
|
+
else localStorage.removeItem('k0-repo-filter')
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The switch itself: which of the two is lit, and whether it is on the bar at all — and with it the
|
|
918
|
+
* repository filter, which is on the bar exactly when the switch is.
|
|
919
|
+
*
|
|
920
|
+
* Both are hidden from here and not from the redraw because the redraw does not always happen:
|
|
921
|
+
* switching the backlog off changes nothing the board compares, so the dropdown would have stayed
|
|
922
|
+
* on the bar, still filtering, over a board that has no filter left to apply. Inside a lane there
|
|
923
|
+
* is one repository and it was settled by going in, so there is nothing to choose there either.
|
|
924
|
+
*/
|
|
925
|
+
function renderColumnsSwitch() {
|
|
926
|
+
$('#columns').hidden = !backlogOn
|
|
927
|
+
const hide = !backlogOn || !!laneEpic
|
|
928
|
+
// Shut before it goes: a menu left open under a hidden element comes back open the next time
|
|
929
|
+
// the element does, over a board nobody asked it to cover.
|
|
930
|
+
if (hide && !$('#repo-filter-list').hidden) showRepoList(false)
|
|
931
|
+
$('#repo-filter').hidden = hide
|
|
932
|
+
// And what the `+` promises. With a backlog behind the board it opens a menu with two things on
|
|
933
|
+
// it, and a button whose label says one of them is a button that lied about the other.
|
|
934
|
+
const plus = backlogOn ? 'New story or epic' : 'New story'
|
|
935
|
+
$('#new-story').title = plus
|
|
936
|
+
$('#new-story').setAttribute('aria-label', plus)
|
|
937
|
+
for (const b of document.querySelectorAll('#columns button')) {
|
|
938
|
+
b.setAttribute('aria-checked', b.dataset.columns === columnsMean ? 'true' : 'false')
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
457
942
|
/** Put a column away by hand, and let go of the place it was holding for this visit. */
|
|
458
943
|
function putAway(path) {
|
|
459
944
|
folded.add(path)
|
|
@@ -482,7 +967,7 @@ function bringBack(path) {
|
|
|
482
967
|
* rest of the visit.
|
|
483
968
|
*
|
|
484
969
|
* It sits next to `Others` and is built the same way, down to the row: a `+`, a name, a git mark
|
|
485
|
-
* and nothing else. How many
|
|
970
|
+
* and nothing else. How many stories are parked in there and how long it has been quiet were both
|
|
486
971
|
* in here once, and both had to go — three numbers on a row you are meant to read at a glance is
|
|
487
972
|
* two too many, and the answer to either is one click away in the column itself.
|
|
488
973
|
*/
|
|
@@ -501,7 +986,7 @@ function oldColumn(columns) {
|
|
|
501
986
|
[...columns]
|
|
502
987
|
.sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
|
|
503
988
|
.map(({ col }) => {
|
|
504
|
-
const add = `New
|
|
989
|
+
const add = `New story in ${esc(col.name)}`
|
|
505
990
|
const back = `Bring ${col.name} back onto the board`
|
|
506
991
|
return `<div class="repo" data-p="${esc(col.path)}">
|
|
507
992
|
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
@@ -523,9 +1008,9 @@ function oldColumn(columns) {
|
|
|
523
1008
|
|
|
524
1009
|
/**
|
|
525
1010
|
* The last column: the OTHER repositories, the ones with no column because they have not one
|
|
526
|
-
*
|
|
1011
|
+
* story between them. Repeating the ones already on the left would be saying the same thing
|
|
527
1012
|
* twice. Headings only, in the order you last used them. It is there for two things: getting
|
|
528
|
-
* into the files of a directory you are not working on, and giving birth to the first
|
|
1013
|
+
* into the files of a directory you are not working on, and giving birth to the first story
|
|
529
1014
|
* there without having to find it in a list.
|
|
530
1015
|
*/
|
|
531
1016
|
function repoColumn(projects, shown) {
|
|
@@ -545,7 +1030,7 @@ function repoColumn(projects, shown) {
|
|
|
545
1030
|
'</small></h2>' +
|
|
546
1031
|
others
|
|
547
1032
|
.map((p) => {
|
|
548
|
-
const add = `New
|
|
1033
|
+
const add = `New story in ${esc(p.name)}`
|
|
549
1034
|
return `<div class="repo" data-p="${esc(p.path)}">
|
|
550
1035
|
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
551
1036
|
<span class="name">${esc(p.name)}</span>${gitChip(p.git, p.path)}
|
|
@@ -565,7 +1050,7 @@ function repoColumn(projects, shown) {
|
|
|
565
1050
|
* is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
|
|
566
1051
|
* while it says all is well, amber at the first warning, red when it is critical.
|
|
567
1052
|
*/
|
|
568
|
-
function renderMachine(m,
|
|
1053
|
+
function renderMachine(m, stories, idleHours = 0) {
|
|
569
1054
|
const el = $('#gauge')
|
|
570
1055
|
if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
|
|
571
1056
|
el.removeAttribute('hidden')
|
|
@@ -577,10 +1062,15 @@ function renderMachine(m, cards, idleHours = 0) {
|
|
|
577
1062
|
// chip and nowhere else: this is where the memory is already being talked about, and a thing
|
|
578
1063
|
// that quietly closes your windows should not be something you have to know about beforehand.
|
|
579
1064
|
// Its presence is the whole message — switched off, there is nothing here rather than a nought.
|
|
580
|
-
const closes = idleHours > 0 ? `<span class="idle"
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1065
|
+
const closes = idleHours > 0 ? `<span class="idle">closes at ${idleHours}h</span>` : ''
|
|
1066
|
+
// RAM over CPU rather than side by side, and the closing time under them rather than beside them.
|
|
1067
|
+
// The four mode buttons went two by two to get the bar back onto one row, and this is the other
|
|
1068
|
+
// half of the same move: the chip gives back the width it was taking by going up instead of
|
|
1069
|
+
// along, and "· closes at 12h" written across was a hundred pixels of it on its own. The dot
|
|
1070
|
+
// stays beside the stack, centred on it.
|
|
1071
|
+
el.innerHTML = `<i></i><span class="nums"><span>RAM ${ram}%</span><span>CPU ${cpu}</span>${closes}</span>`
|
|
1072
|
+
|
|
1073
|
+
const top = stories.find((c) => c.id === heaviest)
|
|
584
1074
|
const lines = [
|
|
585
1075
|
`${gb(m.mem.used)} of ${gb(m.mem.total)} in use · ${gb(m.swap.used)} on swap`,
|
|
586
1076
|
`${m.alive} session${m.alive === 1 ? '' : 's'} running${m.outside ? `, ${m.outside} not on the dashboard` : ''}`,
|
|
@@ -588,14 +1078,23 @@ function renderMachine(m, cards, idleHours = 0) {
|
|
|
588
1078
|
if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
|
|
589
1079
|
// What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
|
|
590
1080
|
if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
|
|
1081
|
+
// And k0 itself, which this chip measured everybody but. The three numbers after the weight are
|
|
1082
|
+
// the ones that were wrong for a long time without anybody being able to see them.
|
|
1083
|
+
if (k0?.rss) {
|
|
1084
|
+
const cpu = k0.cpu === null || k0.cpu === undefined ? '—' : `${Math.round(k0.cpu * 100)}%`
|
|
1085
|
+
lines.push(
|
|
1086
|
+
`k0 itself: ${weight(k0.rss)} · CPU ${cpu} · git ${k0.git}/min · ` +
|
|
1087
|
+
`${k0.sql.kept} queries kept · looking every ${Math.round(k0.pace / 1000)}s`
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
591
1090
|
// The chip has room for three words; the sentence that explains them goes here, and it is also
|
|
592
1091
|
// the only place that can say the closing is switched off — the chip says that by staying quiet.
|
|
593
1092
|
lines.push(
|
|
594
1093
|
idleHours > 0
|
|
595
|
-
? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the
|
|
1094
|
+
? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the story stays, Resume picks it up`
|
|
596
1095
|
: 'closing forgotten terminals is switched off'
|
|
597
1096
|
)
|
|
598
|
-
lines.push(showLoad ? 'click to hide the weight on each
|
|
1097
|
+
lines.push(showLoad ? 'click to hide the weight on each story' : 'click to show the weight on each story')
|
|
599
1098
|
el.title = lines.join('\n')
|
|
600
1099
|
}
|
|
601
1100
|
|
|
@@ -615,34 +1114,154 @@ function toast(msg, ms = 4000) {
|
|
|
615
1114
|
toast.timer = setTimeout(() => t.classList.remove('show'), ms)
|
|
616
1115
|
}
|
|
617
1116
|
|
|
1117
|
+
/**
|
|
1118
|
+
* What a launched terminal is told to say, whichever door it came through. There are three of them
|
|
1119
|
+
* now — the story's own prompt, a command the interface suggested, and an epic that has no story to
|
|
1120
|
+
* belong to — and one sentence about how it went, so they cannot report the same thing three ways.
|
|
1121
|
+
*/
|
|
1122
|
+
function launched(r) {
|
|
1123
|
+
// Switched off, every door into the backlog answers with the same sentence and an HTTP 200 — a
|
|
1124
|
+
// skill has to be able to tell "there is nothing here" from "you turned this off", and neither
|
|
1125
|
+
// of those is an error. `api()` only throws on an error status, so a refusal arrives here looking
|
|
1126
|
+
// like a launch with no window in it, and without this the user would be told the opposite of
|
|
1127
|
+
// what happened about a session that was never opened.
|
|
1128
|
+
if (r.enabled === false) return toast(r.why || 'The k0 backlog is switched off.', 8000)
|
|
1129
|
+
if (!r.up) return toast(`${r.name} was launched, but I didn't see it come up within 30s`)
|
|
1130
|
+
// `autoSent` before `pasted`, and they are not exclusive: a prompt sent with the session goes on
|
|
1131
|
+
// the command line, which counts as pasted too. Asking somebody to hit enter on a session that
|
|
1132
|
+
// is already answering is telling them the opposite of what happened.
|
|
1133
|
+
if (r.autoSent && r.pasted) return toast(`${r.name} is running and the prompt has gone in`)
|
|
1134
|
+
if (r.autoSent) {
|
|
1135
|
+
const why = `without the Accessibility permission I can't leave the prompt waiting`
|
|
1136
|
+
return toast(`${r.name} started on its own (${why})`, 8000)
|
|
1137
|
+
}
|
|
1138
|
+
if (r.pasted) return toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
|
|
1139
|
+
toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
618
1142
|
async function start(id, mode) {
|
|
619
1143
|
toast(mode === 'resume' ? 'Resuming the session…' : 'Opening the terminal…', 30000)
|
|
620
1144
|
try {
|
|
621
|
-
|
|
622
|
-
if (!r.up) toast(`${r.name} was launched, but I didn't see it come up within 30s`)
|
|
623
|
-
else if (r.pasted) toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
|
|
624
|
-
else if (r.autoSent) toast(`${r.name} started on its own (without the Accessibility permission I can't leave the prompt waiting)`, 8000)
|
|
625
|
-
else toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
|
|
1145
|
+
launched(await api(`/api/story/${id}/${mode}`, { method: 'POST' }))
|
|
626
1146
|
} catch (e) {
|
|
627
1147
|
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
628
1148
|
}
|
|
629
1149
|
refresh()
|
|
630
1150
|
}
|
|
631
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* The suggestion, pressed: a session that opens saying `/k0-plan K42` instead of what the note
|
|
1154
|
+
* says. The command is the server's own word — it came down on the story and goes back up
|
|
1155
|
+
* unchanged — and the server holds it against a closed list before it goes anywhere near a command
|
|
1156
|
+
* line, so a refusal here is a sentence and not a silence.
|
|
1157
|
+
*/
|
|
1158
|
+
async function startCommand(story, command) {
|
|
1159
|
+
toast(`Opening the terminal on /${command} ${story.key ?? ''}`.trim() + '…', 30000)
|
|
1160
|
+
try {
|
|
1161
|
+
launched(await api(`/api/backlog/story/${story.id}/start`, {
|
|
1162
|
+
method: 'POST',
|
|
1163
|
+
body: JSON.stringify({ command }),
|
|
1164
|
+
}))
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
1167
|
+
}
|
|
1168
|
+
lastSignature = ''
|
|
1169
|
+
refresh()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* An epic, which is not a row anywhere yet and is not meant to be: a session in that repository
|
|
1174
|
+
* running `/k0-epic`, and the discussion writes the epic down itself once it knows what it is.
|
|
1175
|
+
* That is why this takes a repository and nothing else — there is nothing to type in.
|
|
1176
|
+
*/
|
|
1177
|
+
async function startEpic(path) {
|
|
1178
|
+
toast('Opening the terminal…', 30000)
|
|
1179
|
+
try {
|
|
1180
|
+
launched(await api('/api/backlog/epic/start', { method: 'POST', body: JSON.stringify({ project_path: path }) }))
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
1183
|
+
}
|
|
1184
|
+
lastSignature = ''
|
|
1185
|
+
refresh()
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* The other way an epic can open a terminal: one that exists, and a command told to run on the
|
|
1190
|
+
* whole of it — `/k0-ultracode K7`. It is `startCommand` for something that is not a story, which
|
|
1191
|
+
* is why it cannot be that function: the address is the epic's, and there is no session row at the
|
|
1192
|
+
* end of it to come back with.
|
|
1193
|
+
*/
|
|
1194
|
+
async function startEpicCommand(epic, command) {
|
|
1195
|
+
toast(`Opening the terminal on /${command} ${epic.key ?? ''}`.trim() + '…', 30000)
|
|
1196
|
+
try {
|
|
1197
|
+
launched(await api(`/api/backlog/epic/${epic.id}/start`, { method: 'POST', body: JSON.stringify({ command }) }))
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
1200
|
+
}
|
|
1201
|
+
lastSignature = ''
|
|
1202
|
+
refresh()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
632
1205
|
async function focusTerminal(id) {
|
|
633
|
-
|
|
634
|
-
|
|
1206
|
+
// The answer says `ok: false` when the window could not be brought up, and the request itself
|
|
1207
|
+
// can fail — a story deleted in another tab, the server restarting. Both are the same news to
|
|
1208
|
+
// whoever double-clicked, and neither may be a double click that did nothing.
|
|
1209
|
+
//
|
|
1210
|
+
// `resumable` is the difference between news and a dead end: the window is gone but there is a
|
|
1211
|
+
// conversation behind it, and the one thing worth doing about that is offered rather than
|
|
1212
|
+
// described. Answered with a question and not by resuming on its own — a double click asked for
|
|
1213
|
+
// the window that was there, not for a new one.
|
|
1214
|
+
try {
|
|
1215
|
+
const r = await api(`/api/story/${id}/focus`, { method: 'POST' })
|
|
1216
|
+
if (r.ok) return
|
|
1217
|
+
const said = r.error || 'That terminal could not be brought up front'
|
|
1218
|
+
if (!r.resumable) return toast(said)
|
|
1219
|
+
if (await ask(`${said} Open it again?`, { yes: 'Resume', destructive: false })) start(id, 'resume')
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
toast(`Couldn't do it: ${e.message}`)
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Starting a story, with the one thing the backlog has to say about it: this waits on something
|
|
1227
|
+
* that is not finished.
|
|
1228
|
+
*
|
|
1229
|
+
* It asks and then it goes ahead — it never refuses. A dependency is a note you left yourself, not
|
|
1230
|
+
* a lock, and a backlog that will not let you work on what you want is a backlog you stop using.
|
|
1231
|
+
* Asked once, per click: there is nothing remembered here, because the answer belongs to this
|
|
1232
|
+
* moment and the dependency may well be closed by the next one.
|
|
1233
|
+
*/
|
|
1234
|
+
async function mayStart(story) {
|
|
1235
|
+
const open = waitingOn(story)
|
|
1236
|
+
if (!open.length) return true
|
|
1237
|
+
const list = open.map((d) => `${d.key} ${d.title}`).join(', ')
|
|
1238
|
+
return await ask(`“${story.title}” waits on ${list}, which ${open.length === 1 ? 'is' : 'are'} not done.`, {
|
|
1239
|
+
yes: 'Start anyway',
|
|
1240
|
+
destructive: false,
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function startStory(story) {
|
|
1245
|
+
if (!(await mayStart(story))) return
|
|
1246
|
+
start(story.id, 'start')
|
|
635
1247
|
}
|
|
636
1248
|
|
|
637
1249
|
/**
|
|
638
|
-
* The house confirmation, not the browser's
|
|
639
|
-
*
|
|
640
|
-
* Reopen.
|
|
1250
|
+
* The house confirmation, not the browser's. It is asked before throwing a story away — the one
|
|
1251
|
+
* thing there is no coming back from — and before starting work that waits on something else.
|
|
1252
|
+
* "Done" is not asked about: that one is undone with Reopen.
|
|
1253
|
+
*
|
|
1254
|
+
* The two are not the same question and they must not look the same. Throwing away is red and
|
|
1255
|
+
* says `Delete`; going ahead anyway is the ordinary dark button, because it is the answer you are
|
|
1256
|
+
* expected to give.
|
|
641
1257
|
*/
|
|
642
|
-
function ask(text) {
|
|
1258
|
+
function ask(text, { yes = 'Delete', destructive = true } = {}) {
|
|
643
1259
|
const dlg = $('#confirm')
|
|
644
1260
|
return new Promise((resolve) => {
|
|
645
1261
|
$('#confirm-text').textContent = text
|
|
1262
|
+
const go = $('#confirm-yes')
|
|
1263
|
+
go.textContent = yes
|
|
1264
|
+
go.className = destructive ? 'destructive' : 'primary'
|
|
646
1265
|
const close = (answer) => {
|
|
647
1266
|
dlg.close()
|
|
648
1267
|
resolve(answer)
|
|
@@ -654,20 +1273,26 @@ function ask(text) {
|
|
|
654
1273
|
})
|
|
655
1274
|
}
|
|
656
1275
|
|
|
657
|
-
async function remove(
|
|
658
|
-
if (!(await ask(`Delete “${
|
|
659
|
-
|
|
1276
|
+
async function remove(story) {
|
|
1277
|
+
if (!(await ask(`Delete “${story.title}”?`))) return
|
|
1278
|
+
// A delete that cannot go through must say so. Without this the confirmation closed, the note
|
|
1279
|
+
// stayed exactly where it was, and nothing on the screen had anything to say about it.
|
|
1280
|
+
try {
|
|
1281
|
+
await api(`/api/story/${story.id}`, { method: 'DELETE' })
|
|
1282
|
+
} catch (e) {
|
|
1283
|
+
toast(`Couldn't delete it: ${e.message}`, 8000)
|
|
1284
|
+
}
|
|
660
1285
|
refresh()
|
|
661
1286
|
}
|
|
662
1287
|
|
|
663
1288
|
/**
|
|
664
|
-
* The terminal goes, the
|
|
1289
|
+
* The terminal goes, the story stays. Stopping the session takes a couple of seconds — it is asked
|
|
665
1290
|
* to leave and given the time to — so it says what it is doing while it waits.
|
|
666
1291
|
*/
|
|
667
1292
|
async function closeSession(id) {
|
|
668
1293
|
toast('Closing the terminal…', 10000)
|
|
669
1294
|
try {
|
|
670
|
-
await api(`/api/
|
|
1295
|
+
await api(`/api/story/${id}/close`, { method: 'POST' })
|
|
671
1296
|
toast('Closed. Resume picks the conversation up where it was')
|
|
672
1297
|
} catch (e) {
|
|
673
1298
|
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
@@ -675,33 +1300,98 @@ async function closeSession(id) {
|
|
|
675
1300
|
refresh()
|
|
676
1301
|
}
|
|
677
1302
|
|
|
1303
|
+
/**
|
|
1304
|
+
* Done, and the one answer it can get that is not yes.
|
|
1305
|
+
*
|
|
1306
|
+
* The server refuses a story whose last counter-check found a decision broken, and it refuses it
|
|
1307
|
+
* with the reason written out. Without the catch that refusal was thrown into nothing: no toast,
|
|
1308
|
+
* no redraw, a post-it that sat exactly as it was — the most used button in k0 doing nothing at
|
|
1309
|
+
* all, on the one no the whole feature exists to say. It is shown for longer than the others
|
|
1310
|
+
* because it is a paragraph and not an acknowledgement.
|
|
1311
|
+
*/
|
|
678
1312
|
async function setCompleted(id, completed) {
|
|
679
|
-
|
|
1313
|
+
try {
|
|
1314
|
+
await api(`/api/story/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
toast(refusal(e), 12000)
|
|
1317
|
+
}
|
|
680
1318
|
refresh()
|
|
681
1319
|
}
|
|
682
1320
|
|
|
683
|
-
// ──
|
|
1321
|
+
// ── Making something ───────────────────────────────────────────────
|
|
1322
|
+
/**
|
|
1323
|
+
* The little menu under the `+`.
|
|
1324
|
+
*
|
|
1325
|
+
* It is a menu and not a second dialog because the choice is one word long — and because the two
|
|
1326
|
+
* words are the whole difference between them: a story is typed here, an epic is told. It only
|
|
1327
|
+
* exists where there is a backlog; without one the `+` goes straight to the story dialog it always
|
|
1328
|
+
* went to.
|
|
1329
|
+
*/
|
|
1330
|
+
function toggleNewMenu(open = null) {
|
|
1331
|
+
const menu = $('#new-menu')
|
|
1332
|
+
const show = open === null ? menu.hidden : open
|
|
1333
|
+
menu.hidden = !show
|
|
1334
|
+
$('#new-story').setAttribute('aria-expanded', show ? 'true' : 'false')
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* The epic dialog, which asks for a repository and nothing else.
|
|
1339
|
+
*
|
|
1340
|
+
* There is no title field and there is not going to be one. An epic is what is left when a
|
|
1341
|
+
* discussion has finished arguing about what the work is; typing a name for it first is naming
|
|
1342
|
+
* something nobody has decided the shape of yet, and that name is then what every story under it
|
|
1343
|
+
* inherits. So this opens a terminal and `/k0-epic` does the asking.
|
|
1344
|
+
*/
|
|
1345
|
+
function openEpicDialog() {
|
|
1346
|
+
const sel = $('#e-project')
|
|
1347
|
+
// In the order they were last used, which for this one dialog is the right order: you are about
|
|
1348
|
+
// to spend an hour talking about a repository, and it is almost always the one you were just in.
|
|
1349
|
+
sel.innerHTML = projects
|
|
1350
|
+
.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`)
|
|
1351
|
+
.join('')
|
|
1352
|
+
const here = laneEpic?.path || repoFilter || projects[0]?.path
|
|
1353
|
+
if (here) sel.value = here
|
|
1354
|
+
$('#epic-new').showModal()
|
|
1355
|
+
}
|
|
1356
|
+
|
|
684
1357
|
/**
|
|
685
1358
|
* `presetPath` is the repository of the column you pressed "+" on: it arrives already chosen and
|
|
686
1359
|
* the cursor jumps straight to the title, which is the only thing missing.
|
|
687
1360
|
*/
|
|
688
|
-
function openEditor(
|
|
689
|
-
editing =
|
|
690
|
-
|
|
1361
|
+
function openEditor(story, presetPath = null) {
|
|
1362
|
+
editing = story?.id ?? null
|
|
1363
|
+
editingStory = story ?? null
|
|
691
1364
|
// A repository typed by hand does not count: only a real path counts, taken from the list or
|
|
692
1365
|
// handed over by the column.
|
|
693
|
-
chosenProject =
|
|
694
|
-
$('#editor-title').textContent =
|
|
1366
|
+
chosenProject = story?.project_path ?? presetPath ?? null
|
|
1367
|
+
$('#editor-title').textContent = story ? 'Story' : 'New story'
|
|
695
1368
|
$('#f-project').value = chosenProject ? nameOf(chosenProject) : ''
|
|
696
1369
|
// Session already started: the repository cannot be moved out from under Claude's feet.
|
|
697
|
-
$('#f-project').disabled = !!
|
|
1370
|
+
$('#f-project').disabled = !!story?.session_id
|
|
698
1371
|
$('#f-project').classList.remove('bad')
|
|
699
|
-
$('#f-title').value =
|
|
700
|
-
|
|
701
|
-
|
|
1372
|
+
$('#f-title').value = story?.title ?? ''
|
|
1373
|
+
// Notes and the flag are things you have an opinion about once the story exists and you have
|
|
1374
|
+
// been back to it. Asked at the moment of writing it down they are two fields between you and
|
|
1375
|
+
// the only two things there are to do with a new story.
|
|
1376
|
+
$('#f-notes-row').hidden = !story
|
|
1377
|
+
$('#f-notes').value = story?.description ?? ''
|
|
1378
|
+
// The one the backlog adds. It is not on the dialog at all with the feature off: an empty field
|
|
1379
|
+
// for something that does not exist is a question nobody can answer. The flag is not here at
|
|
1380
|
+
// all — it is put on and taken off from the note, which is where you are when you decide.
|
|
1381
|
+
$('#f-epic-row').hidden = !backlogOn
|
|
1382
|
+
$('#f-epic').value = story?.epic_title ?? ''
|
|
1383
|
+
fillEpics()
|
|
1384
|
+
$('#f-delete').style.display = story ? '' : 'none'
|
|
1385
|
+
// Saving without starting anything is what editing a story is for. A NEW story on a board with
|
|
1386
|
+
// a backlog does not have it: there are two roads out of this dialog and both of them open a
|
|
1387
|
+
// terminal — the long way round through a discussion, or straight in. A board with the backlog
|
|
1388
|
+
// switched off keeps it, because that board is the board it always was.
|
|
1389
|
+
$('#f-save').style.display = story || !backlogOn ? '' : 'none'
|
|
1390
|
+
// The long way in, and only where there is a backlog to discuss anything into.
|
|
1391
|
+
$('#f-discuss').style.display = !story && backlogOn ? '' : 'none'
|
|
702
1392
|
// A live session is not restarted: there is only saving to do there.
|
|
703
|
-
$('#f-start').style.display =
|
|
704
|
-
$('#f-start').textContent =
|
|
1393
|
+
$('#f-start').style.display = story?.session_alive ? 'none' : ''
|
|
1394
|
+
$('#f-start').textContent = story?.session_id ? 'Resume' : 'Quick Start'
|
|
705
1395
|
$('#f-project-list').hidden = true
|
|
706
1396
|
$('#editor').showModal()
|
|
707
1397
|
;(chosenProject ? $('#f-title') : $('#f-project')).focus()
|
|
@@ -710,6 +1400,86 @@ function openEditor(card, presetPath = null) {
|
|
|
710
1400
|
|
|
711
1401
|
const nameOf = (p) => projects.find((x) => x.path === p)?.name ?? p
|
|
712
1402
|
|
|
1403
|
+
/** The epics of the repository the dialog is on. An epic belongs to one repository and only one. */
|
|
1404
|
+
const epicsHere = () => allEpics.filter((e) => e.project_path === chosenProject)
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The names the epic field offers. A datalist and not a `<select>`, because the field has to do two
|
|
1408
|
+
* things at once: pick one of the epics this repository already has, or name one that does not
|
|
1409
|
+
* exist yet — and a dropdown cannot be typed into.
|
|
1410
|
+
*
|
|
1411
|
+
* The key is the option's label rather than its value: what you leave in the field is a name, so
|
|
1412
|
+
* that a name typed from scratch and a name picked from the list are the same thing to `save`.
|
|
1413
|
+
*/
|
|
1414
|
+
function fillEpics() {
|
|
1415
|
+
$('#f-epics').innerHTML = epicsHere()
|
|
1416
|
+
.map((e) => `<option value="${esc(e.title)}">${esc(e.key)}</option>`)
|
|
1417
|
+
.join('')
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Which epic the dialog is asking for, as a key: one that already exists, one made on the spot, or
|
|
1422
|
+
* `null` for none.
|
|
1423
|
+
*
|
|
1424
|
+
* Made on the spot is the whole point of the field. An epic wanted while writing a story is an epic
|
|
1425
|
+
* wanted now, and sending somebody to another screen to make one first is how a story ends up
|
|
1426
|
+
* outside the epic it belongs to for ever.
|
|
1427
|
+
*/
|
|
1428
|
+
async function chosenEpic() {
|
|
1429
|
+
const name = $('#f-epic').value.trim()
|
|
1430
|
+
if (!name) return null
|
|
1431
|
+
const q = name.toLowerCase()
|
|
1432
|
+
const found = epicsHere().find((e) => e.title.toLowerCase() === q || e.key.toLowerCase() === q)
|
|
1433
|
+
if (found) return found.key
|
|
1434
|
+
const made = await api('/api/backlog/epic', {
|
|
1435
|
+
method: 'POST',
|
|
1436
|
+
body: JSON.stringify({ project_path: chosenProject, title: name }),
|
|
1437
|
+
})
|
|
1438
|
+
return made.key
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The two fields that are the backlog's and not the board's, written after the story itself.
|
|
1443
|
+
*
|
|
1444
|
+
* They go through `/api/backlog/story/:id`, which is the door that resolves an epic's key and
|
|
1445
|
+
* writes the `.k0/` file; the board's own door knows about neither. It is a second request and it
|
|
1446
|
+
* is worth it: it means the story is saved whatever happens here, and if the epic cannot be made —
|
|
1447
|
+
* a repository gone read-only, a name that collides — what is said is that the epic did not happen,
|
|
1448
|
+
* not that the story did not.
|
|
1449
|
+
*/
|
|
1450
|
+
async function stampBacklog(id, story) {
|
|
1451
|
+
if (!backlogOn) return
|
|
1452
|
+
const before = story?.epic_title ?? ''
|
|
1453
|
+
const typed = $('#f-epic').value.trim()
|
|
1454
|
+
if (typed === before) return
|
|
1455
|
+
try {
|
|
1456
|
+
const change = { epic_key: await chosenEpic() }
|
|
1457
|
+
await api(`/api/backlog/story/${id}`, { method: 'PATCH', body: JSON.stringify(change) })
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
toast(`The story is saved, but: ${e.message}`, 8000)
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* On and off, from the note itself.
|
|
1465
|
+
*
|
|
1466
|
+
* Through the backlog's own door and not the board's: it is the one that writes the `.k0/` file,
|
|
1467
|
+
* so a flag put on here is on the disk copy too and survives a lost database like everything else
|
|
1468
|
+
* about the story.
|
|
1469
|
+
*/
|
|
1470
|
+
async function setFlag(story) {
|
|
1471
|
+
try {
|
|
1472
|
+
await api(`/api/backlog/story/${story.id}`, {
|
|
1473
|
+
method: 'PATCH',
|
|
1474
|
+
body: JSON.stringify({ starred: !story.starred }),
|
|
1475
|
+
})
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
1478
|
+
}
|
|
1479
|
+
lastSignature = ''
|
|
1480
|
+
refresh()
|
|
1481
|
+
}
|
|
1482
|
+
|
|
713
1483
|
let hits = [] // the repositories shown in the list right now
|
|
714
1484
|
let active = 0 // where you are with the arrows: the row Enter picks
|
|
715
1485
|
|
|
@@ -718,6 +1488,8 @@ function chooseProject(path) {
|
|
|
718
1488
|
$('#f-project').value = nameOf(path)
|
|
719
1489
|
$('#f-project').classList.remove('bad')
|
|
720
1490
|
$('#f-project-list').hidden = true
|
|
1491
|
+
// An epic belongs to one repository: changing the repository changes which ones are on offer.
|
|
1492
|
+
fillEpics()
|
|
721
1493
|
}
|
|
722
1494
|
|
|
723
1495
|
/** With a repository already chosen the whole list is shown: that is where you change it. */
|
|
@@ -774,7 +1546,25 @@ function resolveProject() {
|
|
|
774
1546
|
el.classList.remove('bad')
|
|
775
1547
|
}
|
|
776
1548
|
|
|
777
|
-
/**
|
|
1549
|
+
/**
|
|
1550
|
+
* The notes, as a list.
|
|
1551
|
+
*
|
|
1552
|
+
* A dash is put in front of every line that has not got one, on the way to being saved rather than
|
|
1553
|
+
* as you type: a box that rewrites the line under the cursor is a box that fights you in the middle
|
|
1554
|
+
* of a word. Blank lines go, because a bullet with nothing after it is not a note.
|
|
1555
|
+
*
|
|
1556
|
+
* It is what makes the box a list and not a paragraph — which is the whole difference between
|
|
1557
|
+
* somewhere to jot three things down and somewhere to write an essay nobody will read again.
|
|
1558
|
+
*/
|
|
1559
|
+
const bulleted = (text) =>
|
|
1560
|
+
String(text ?? '')
|
|
1561
|
+
.split('\n')
|
|
1562
|
+
.map((line) => line.trim())
|
|
1563
|
+
.filter(Boolean)
|
|
1564
|
+
.map((line) => (/^[-*•]\s*/.test(line) ? line.replace(/^[-*•]\s*/, '- ') : `- ${line}`))
|
|
1565
|
+
.join('\n')
|
|
1566
|
+
|
|
1567
|
+
/** Returns the saved story, or null if it could not be saved. */
|
|
778
1568
|
async function save() {
|
|
779
1569
|
if (!$('#editor-form').reportValidity()) return null
|
|
780
1570
|
const title = titleCase($('#f-title').value).replace(/-+$/, '')
|
|
@@ -790,20 +1580,19 @@ async function save() {
|
|
|
790
1580
|
toast('A title is required')
|
|
791
1581
|
return null
|
|
792
1582
|
}
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
const payload = {
|
|
798
|
-
|
|
799
|
-
project_path: chosenProject,
|
|
800
|
-
prompt: $('#f-prompt').value,
|
|
801
|
-
}
|
|
1583
|
+
// The notes go in only when the dialog was showing them, which is to say only when a story was
|
|
1584
|
+
// being edited. Leaving the key out of the payload is what keeps what is already there:
|
|
1585
|
+
// `patchStory` writes only the keys it is given, so a new story is written without touching a
|
|
1586
|
+
// description an import may later put on it.
|
|
1587
|
+
const payload = { title, project_path: chosenProject }
|
|
1588
|
+
if (!$('#f-notes-row').hidden) payload.description = bulleted($('#f-notes').value)
|
|
802
1589
|
try {
|
|
803
1590
|
const saved = editing
|
|
804
|
-
? await api(`/api/
|
|
805
|
-
: await api('/api/
|
|
1591
|
+
? await api(`/api/story/${editing}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
|
1592
|
+
: await api('/api/story', { method: 'POST', body: JSON.stringify(payload) })
|
|
1593
|
+
await stampBacklog(saved.id, editingStory)
|
|
806
1594
|
$('#editor').close()
|
|
1595
|
+
lastSignature = ''
|
|
807
1596
|
refresh()
|
|
808
1597
|
return saved
|
|
809
1598
|
} catch (e) {
|
|
@@ -859,10 +1648,84 @@ function setMode(mode, reason) {
|
|
|
859
1648
|
}
|
|
860
1649
|
}
|
|
861
1650
|
|
|
1651
|
+
/**
|
|
1652
|
+
* The dot beside the name, and the sentence it carries.
|
|
1653
|
+
*
|
|
1654
|
+
* Two different pieces of news share one mark: k0 moved on under you while you were not looking,
|
|
1655
|
+
* and npm has something newer than what is installed here. They are not the same thing and the
|
|
1656
|
+
* title says which, because a mark that could mean either is a mark you learn to ignore.
|
|
1657
|
+
*
|
|
1658
|
+
* It is a link and nothing else. Nothing here opens the page, and there is no number on the dot:
|
|
1659
|
+
* there is nothing to count, and a badge would make an upgrade feel like an unread inbox.
|
|
1660
|
+
*/
|
|
1661
|
+
function setWhatsNew(u) {
|
|
1662
|
+
const mark = $('#whatsnew')
|
|
1663
|
+
if (!mark) return
|
|
1664
|
+
const jumped = u?.jump ? `k0 went from ${u.jump.from} to ${u.jump.to} — see what changed` : null
|
|
1665
|
+
const newer = u?.latest?.newer ? `npm has k0 ${u.latest.version}; this one is ${u.version}` : null
|
|
1666
|
+
const why = jumped || newer
|
|
1667
|
+
mark.hidden = !why
|
|
1668
|
+
if (why) {
|
|
1669
|
+
mark.title = why
|
|
1670
|
+
mark.setAttribute('aria-label', why)
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* What the list view is lent, and the whole of what it is lent.
|
|
1676
|
+
*
|
|
1677
|
+
* It is handed over rather than imported from the other side, so the list has no way to reach back
|
|
1678
|
+
* into the board: everything it can do to a story is on this object, and the day one of these
|
|
1679
|
+
* changes there is one place that says so. `redraw` is the pair of lines every write in here ends
|
|
1680
|
+
* with — forget what is on screen, and ask again.
|
|
1681
|
+
*/
|
|
1682
|
+
const LIST = {
|
|
1683
|
+
api,
|
|
1684
|
+
toast,
|
|
1685
|
+
hue: epicHue,
|
|
1686
|
+
flag: ICON.flag,
|
|
1687
|
+
next: doNextStep,
|
|
1688
|
+
// The one thing the panel can start that a note cannot: a command told to run on a whole epic.
|
|
1689
|
+
epicCommand: startEpicCommand,
|
|
1690
|
+
redraw: () => {
|
|
1691
|
+
lastSignature = ''
|
|
1692
|
+
refresh()
|
|
1693
|
+
},
|
|
1694
|
+
}
|
|
1695
|
+
|
|
862
1696
|
// ── The loop ──────────────────────────────────────────────────────
|
|
1697
|
+
/**
|
|
1698
|
+
* The last answer that had a board in it, and the stamp that came with it.
|
|
1699
|
+
*
|
|
1700
|
+
* The stamp goes back to the server on the next round, and if the board would be the same answer
|
|
1701
|
+
* twice the server says so in a couple of hundred bytes instead of sending two thirds of a
|
|
1702
|
+
* megabyte again. On a board nobody is touching that is every round of every second of every day:
|
|
1703
|
+
* it came to fifty-six gigabytes through the browser in twenty-four hours, to draw post-its that
|
|
1704
|
+
* had not moved.
|
|
1705
|
+
*/
|
|
1706
|
+
let lastBoard = null
|
|
1707
|
+
let stamp = ''
|
|
1708
|
+
|
|
863
1709
|
async function refresh() {
|
|
864
1710
|
try {
|
|
865
|
-
const
|
|
1711
|
+
const answer = await api(`/api/board${stamp ? `?since=${encodeURIComponent(stamp)}` : ''}`)
|
|
1712
|
+
stamp = answer.stamp ?? ''
|
|
1713
|
+
// The first answer is always a whole one — the stamp starts empty — so `held` cannot be null
|
|
1714
|
+
// here unless the server is older than this page, and then it simply never says `same`.
|
|
1715
|
+
const settled = answer.same ? lastBoard : (lastBoard = answer)
|
|
1716
|
+
// A server older than this page: it never says `same`, so this cannot happen twice.
|
|
1717
|
+
if (!settled) return true
|
|
1718
|
+
|
|
1719
|
+
// The weights travel apart from the stories, because they are the one thing on a post-it that
|
|
1720
|
+
// changes every second, and put back on here so everything below reads a story the way it
|
|
1721
|
+
// always has. Only a live session has one.
|
|
1722
|
+
const loads = answer.loads ?? {}
|
|
1723
|
+
const data = {
|
|
1724
|
+
...settled,
|
|
1725
|
+
...answer,
|
|
1726
|
+
stories: settled.stories.map((s) => (loads[s.id] ? { ...s, load: loads[s.id] } : s)),
|
|
1727
|
+
}
|
|
1728
|
+
|
|
866
1729
|
// The icon in the tab is tinted like the one in the menu bar: red if a session is waiting
|
|
867
1730
|
// for you, blue if they are grinding away, outline if everything is still.
|
|
868
1731
|
setFavicon(data.urgent)
|
|
@@ -871,24 +1734,67 @@ async function refresh() {
|
|
|
871
1734
|
// is not covered, the lit button has to say so instead of pretending otherwise.
|
|
872
1735
|
|
|
873
1736
|
setMode(data.mode, data.reason)
|
|
1737
|
+
setWhatsNew(data.update)
|
|
1738
|
+
const wasOn = backlogOn
|
|
1739
|
+
backlogOn = !!data.backlog
|
|
1740
|
+
// Every epic k0 knows, kept for the editor's list: the dialog is opened between rounds and has
|
|
1741
|
+
// to be able to offer them without a request of its own.
|
|
1742
|
+
allEpics = data.epics ?? []
|
|
1743
|
+
// The switch belongs to the bar and not to the board: it has to appear the moment the server
|
|
1744
|
+
// says there is a backlog. Only then, though — it used to be rewritten every second to say
|
|
1745
|
+
// the same thing.
|
|
1746
|
+
if (wasOn !== backlogOn) renderColumnsSwitch()
|
|
874
1747
|
machine = data.machine ?? null
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1748
|
+
k0 = data.k0 ?? null
|
|
1749
|
+
// What is eating most right now: it is what tints that one red and nobody else. Read off the
|
|
1750
|
+
// weights rather than by sorting every story on the board, since only a handful have one.
|
|
1751
|
+
heaviest = Number(Object.entries(loads).sort((a, b) => b[1].rss - a[1].rss)[0]?.[0]) || null
|
|
1752
|
+
renderMachine(machine, data.stories, data.closeIdleAfterHours)
|
|
879
1753
|
|
|
880
1754
|
// Redraw only if something really changed, so the editor and the focus stay where they are.
|
|
881
1755
|
// The git state is inside the signature: without it, the mark would stay the one from the
|
|
882
1756
|
// first round. The weight goes in already rounded — see `weight` — otherwise the board
|
|
883
1757
|
// would redraw on every sample of the load.
|
|
1758
|
+
// Nothing the server sent can have moved: it hashed exactly what it would have sent, and the
|
|
1759
|
+
// hash matched. What is left in the signature below is this page's own state — the filters,
|
|
1760
|
+
// the lane, which way the columns are read — and every one of those clears `lastSignature`
|
|
1761
|
+
// itself the moment you change it. The only other one is the heaviest story, and that
|
|
1762
|
+
// counts only while the machine is under pressure. So there is nothing to compare: no second
|
|
1763
|
+
// pass over six hundred stories, and no string a hundred kilobytes long built to prove that
|
|
1764
|
+
// nothing happened.
|
|
1765
|
+
if (answer.same && lastSignature && machine?.pressure === 'ok') return false
|
|
1766
|
+
|
|
884
1767
|
const sig = JSON.stringify([
|
|
885
1768
|
showLoad,
|
|
886
1769
|
// The column order depends on it: changing mode means the board has to be rebuilt.
|
|
887
1770
|
data.mode,
|
|
1771
|
+
// Switching the backlog off takes a button off every post-it that has never been started,
|
|
1772
|
+
// and a change nothing in here noticed would only show up the next time anything else did.
|
|
1773
|
+
backlogOn,
|
|
1774
|
+
// What a column means, which repository the state columns are showing, and which epic the
|
|
1775
|
+
// board is standing inside: three choices that rebuild the whole board and none of which
|
|
1776
|
+
// the server knows anything about.
|
|
1777
|
+
columnsMean,
|
|
1778
|
+
repoFilter,
|
|
1779
|
+
laneEpic && `${laneEpic.path}:${laneEpic.key}`,
|
|
1780
|
+
// The epics, for the progress on the strip at the top and for the labels on the notes. Only
|
|
1781
|
+
// with the backlog on: off, there are none, and a null here is one fewer thing to compare.
|
|
1782
|
+
backlogOn
|
|
1783
|
+
? (data.epics ?? []).map((e) => [
|
|
1784
|
+
e.key,
|
|
1785
|
+
e.project_path,
|
|
1786
|
+
e.title,
|
|
1787
|
+
e.progress.done,
|
|
1788
|
+
e.progress.total,
|
|
1789
|
+
// Which round the discussion is on. The list draws it, and it is the only thing moving
|
|
1790
|
+
// on an epic that has not turned into stories yet — which is most of an epic's life.
|
|
1791
|
+
e.round?.n ?? '',
|
|
1792
|
+
])
|
|
1793
|
+
: null,
|
|
888
1794
|
// Not `heaviest` as it is: on a calm machine the heaviest changes constantly without
|
|
889
1795
|
// anything changing on screen, and it would redraw for nothing.
|
|
890
1796
|
machine?.pressure !== 'ok' ? heaviest : null,
|
|
891
|
-
data.
|
|
1797
|
+
data.stories.map((c) => [
|
|
892
1798
|
c.id,
|
|
893
1799
|
c.status,
|
|
894
1800
|
c.session_alive,
|
|
@@ -902,6 +1808,19 @@ async function refresh() {
|
|
|
902
1808
|
c.updated_at,
|
|
903
1809
|
gitSig(c.git),
|
|
904
1810
|
c.load ? weight(c.load.rss) : '',
|
|
1811
|
+
// What the backlog draws on the note and on a row in the list. They are all `undefined`
|
|
1812
|
+
// with the feature off, which compares as well as anything else.
|
|
1813
|
+
c.state,
|
|
1814
|
+
c.starred,
|
|
1815
|
+
c.epic_key,
|
|
1816
|
+
c.epic_title,
|
|
1817
|
+
c.blocked,
|
|
1818
|
+
// The label on the one button that says what to do next. It can change without anything
|
|
1819
|
+
// else on the row changing, and a suggestion nothing noticed would stay wrong until
|
|
1820
|
+
// something else moved. The command as well as the label, because `Ultracode` appears and
|
|
1821
|
+
// disappears with the command behind it and not with the words on the button.
|
|
1822
|
+
c.next_step?.label ?? '',
|
|
1823
|
+
c.next_step?.command ?? '',
|
|
905
1824
|
]),
|
|
906
1825
|
// The server state belongs in here for the same reason the git state does: without it the
|
|
907
1826
|
// globe would keep the colour it had on the first round for the rest of the visit.
|
|
@@ -912,8 +1831,11 @@ async function refresh() {
|
|
|
912
1831
|
lastSignature = sig
|
|
913
1832
|
render(data)
|
|
914
1833
|
}
|
|
1834
|
+
// Whether this round was worth making, which is what decides the pace of the next one.
|
|
1835
|
+
return !answer.same
|
|
915
1836
|
} catch {
|
|
916
|
-
/* server down: we try again next round */
|
|
1837
|
+
/* server down: we try again next round, and at the working pace — this is not a quiet board */
|
|
1838
|
+
return true
|
|
917
1839
|
}
|
|
918
1840
|
}
|
|
919
1841
|
|
|
@@ -930,6 +1852,7 @@ async function loadProjects() {
|
|
|
930
1852
|
async function boot() {
|
|
931
1853
|
initView() // before anything else: render() assumes the view is already there
|
|
932
1854
|
renderFilters()
|
|
1855
|
+
renderColumnsSwitch() // hidden until the first answer says there is a backlog
|
|
933
1856
|
await loadProjects()
|
|
934
1857
|
|
|
935
1858
|
$('#filters').onclick = (e) => {
|
|
@@ -943,7 +1866,7 @@ async function boot() {
|
|
|
943
1866
|
refresh()
|
|
944
1867
|
}
|
|
945
1868
|
|
|
946
|
-
// The gauge is always there; clicking it switches the weights on the
|
|
1869
|
+
// The gauge is always there; clicking it switches the weights on the stories off and on again,
|
|
947
1870
|
// and the choice stays as you left it.
|
|
948
1871
|
$('#gauge').onclick = () => {
|
|
949
1872
|
showLoad = !showLoad
|
|
@@ -976,17 +1899,104 @@ async function boot() {
|
|
|
976
1899
|
}
|
|
977
1900
|
}
|
|
978
1901
|
|
|
979
|
-
|
|
1902
|
+
// Kanban or List. It is a radiogroup like the modes are, and it behaves like one: clicking the
|
|
1903
|
+
// lit half does nothing, because you leave a view by going to the other one.
|
|
1904
|
+
$('#columns').onclick = (e) => {
|
|
1905
|
+
const b = e.target.closest('button[data-columns]')
|
|
1906
|
+
if (!b || b.dataset.columns === columnsMean) return
|
|
1907
|
+
columnsMean = b.dataset.columns
|
|
1908
|
+
localStorage.setItem('k0-columns', columnsMean)
|
|
1909
|
+
renderColumnsSwitch()
|
|
1910
|
+
lastSignature = ''
|
|
1911
|
+
refresh()
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// The repository menu: a button that opens a list with a search in it. Everything that closes
|
|
1915
|
+
// it goes through `showRepoList`, so there is no way to leave the arrow saying one thing and the
|
|
1916
|
+
// list doing another.
|
|
1917
|
+
$('#repo-filter-btn').onclick = (e) => {
|
|
1918
|
+
e.stopPropagation()
|
|
1919
|
+
showRepoList($('#repo-filter-list').hidden)
|
|
1920
|
+
}
|
|
1921
|
+
$('#repo-filter-q').oninput = (e) => paintRepoList(e.target.value)
|
|
1922
|
+
$('#repo-filter-q').onkeydown = (e) => {
|
|
1923
|
+
if (e.key === 'Escape') {
|
|
1924
|
+
e.stopPropagation()
|
|
1925
|
+
showRepoList(false)
|
|
1926
|
+
$('#repo-filter-btn').focus()
|
|
1927
|
+
return
|
|
1928
|
+
}
|
|
1929
|
+
// Enter takes the first row that is left, which is the one the search put at the top: typing
|
|
1930
|
+
// three letters and pressing Enter is the whole gesture, without reaching for the pointer.
|
|
1931
|
+
if (e.key !== 'Enter') return
|
|
1932
|
+
e.preventDefault()
|
|
1933
|
+
$('#repo-filter-rows').querySelector('[data-repo]')?.click()
|
|
1934
|
+
}
|
|
1935
|
+
$('#repo-filter-rows').onclick = (e) => {
|
|
1936
|
+
const row = e.target.closest('[data-repo]')
|
|
1937
|
+
if (!row) return
|
|
1938
|
+
chooseRepo(row.dataset.repo)
|
|
1939
|
+
showRepoList(false)
|
|
1940
|
+
lastSignature = ''
|
|
1941
|
+
refresh()
|
|
1942
|
+
}
|
|
1943
|
+
// Anywhere else on the page shuts it. A menu you have to press the button again to be rid of is
|
|
1944
|
+
// a menu that stays open over the board.
|
|
1945
|
+
document.addEventListener('click', (e) => {
|
|
1946
|
+
if (!$('#repo-filter-list').hidden && !e.target.closest('#repo-filter')) showRepoList(false)
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
// The way out of an epic. It is a button with words on it and not an ✕: going back to the whole
|
|
1950
|
+
// board is not closing something, and there is nothing here to close.
|
|
1951
|
+
$('#epic-out').onclick = () => setLane(null)
|
|
1952
|
+
|
|
1953
|
+
// The `+` has two things to make now, and they are made in two different ways: a story is typed
|
|
1954
|
+
// here, an epic is told to Claude in a terminal. With the backlog off there is only one of them
|
|
1955
|
+
// and the menu never opens — the plus is the plus it has always been.
|
|
1956
|
+
$('#new-story').onclick = () => (backlogOn ? toggleNewMenu() : openEditor(null))
|
|
1957
|
+
$('#new-menu').onclick = (e) => {
|
|
1958
|
+
const b = e.target.closest('button[data-new]')
|
|
1959
|
+
if (!b) return
|
|
1960
|
+
toggleNewMenu(false)
|
|
1961
|
+
if (b.dataset.new === 'story') openEditor(null)
|
|
1962
|
+
else openEpicDialog()
|
|
1963
|
+
}
|
|
1964
|
+
// Anywhere else shuts it, which is what a menu does. It is caught on the way down so a click on
|
|
1965
|
+
// something else still does that something else.
|
|
1966
|
+
document.addEventListener('pointerdown', (e) => {
|
|
1967
|
+
if (!e.target.closest('.new-wrap')) toggleNewMenu(false)
|
|
1968
|
+
})
|
|
1969
|
+
document.addEventListener('keydown', (e) => {
|
|
1970
|
+
if (e.key === 'Escape') toggleNewMenu(false)
|
|
1971
|
+
})
|
|
1972
|
+
|
|
1973
|
+
$('#e-close').onclick = () => $('#epic-new').close()
|
|
1974
|
+
$('#e-cancel').onclick = () => $('#epic-new').close()
|
|
1975
|
+
$('#epic-form').onsubmit = (e) => {
|
|
1976
|
+
e.preventDefault()
|
|
1977
|
+
const path = $('#e-project').value
|
|
1978
|
+
if (!path) return
|
|
1979
|
+
$('#epic-new').close()
|
|
1980
|
+
startEpic(path)
|
|
1981
|
+
}
|
|
980
1982
|
$('#f-close').onclick = () => $('#editor').close()
|
|
981
1983
|
$('#editor-form').onsubmit = (e) => {
|
|
982
1984
|
e.preventDefault()
|
|
983
1985
|
save()
|
|
984
1986
|
}
|
|
985
|
-
// Save and go: the same thing the button on the
|
|
1987
|
+
// Save and go: the same thing the button on the story does, without going through it. Nothing
|
|
1988
|
+
// is sent with it — the terminal opens empty and you type there, which is the whole of what
|
|
1989
|
+
// makes this the quick way in.
|
|
986
1990
|
$('#f-start').onclick = async () => {
|
|
987
1991
|
const saved = await save()
|
|
988
1992
|
if (!saved) return
|
|
989
|
-
start(saved.id,
|
|
1993
|
+
start(saved.id, editingStory?.session_id ? 'resume' : 'start')
|
|
1994
|
+
}
|
|
1995
|
+
// And the long way round: the story is written down and the discussion starts on it at once,
|
|
1996
|
+
// which is the same `/k0-discuss K42` the post-it's own button would have opened later.
|
|
1997
|
+
$('#f-discuss').onclick = async () => {
|
|
1998
|
+
const saved = await save()
|
|
1999
|
+
if (saved) startCommand(saved, 'k0-discuss')
|
|
990
2000
|
}
|
|
991
2001
|
// Esc with the list open closes only the list: the dialog leaves on the second Esc.
|
|
992
2002
|
$('#editor').oncancel = (e) => {
|
|
@@ -1048,16 +2058,36 @@ async function boot() {
|
|
|
1048
2058
|
|
|
1049
2059
|
$('#f-delete').onclick = async () => {
|
|
1050
2060
|
if (!editing || !(await ask(`Delete “${$('#f-title').value}”?`))) return
|
|
1051
|
-
|
|
2061
|
+
// The editor stays open when the delete does not go through: closing it would leave the story
|
|
2062
|
+
// on the board with nothing said about why it is still there.
|
|
2063
|
+
try {
|
|
2064
|
+
await api(`/api/story/${editing}`, { method: 'DELETE' })
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
toast(`Couldn't delete it: ${e.message}`, 8000)
|
|
2067
|
+
return
|
|
2068
|
+
}
|
|
1052
2069
|
$('#editor').close()
|
|
1053
2070
|
refresh()
|
|
1054
2071
|
}
|
|
1055
2072
|
|
|
1056
2073
|
refresh()
|
|
1057
|
-
|
|
2074
|
+
// Only while the tab is in front of you, and only as often as it is worth: see `whileVisible`.
|
|
2075
|
+
// A still board drops to one round every four seconds, and the first thing that moves — or your
|
|
2076
|
+
// coming back to the tab — puts it straight back to one.
|
|
2077
|
+
whileVisible(refresh, 1000, 4000)
|
|
1058
2078
|
|
|
1059
|
-
// A direct link: k0 opened with the new
|
|
2079
|
+
// A direct link: k0 opened with the new story already in front of you.
|
|
1060
2080
|
if (location.hash === '#new' || location.hash === '#nuovo') openEditor(null)
|
|
2081
|
+
// And the other direct link, the one the Usage page makes: a session's story, already open.
|
|
2082
|
+
const wanted = /^#story-(\d+)$/.exec(location.hash)
|
|
2083
|
+
if (wanted) {
|
|
2084
|
+
api('/api/board')
|
|
2085
|
+
.then((d) => {
|
|
2086
|
+
const story = d.stories.find((s) => s.id === Number(wanted[1]))
|
|
2087
|
+
if (story) openEditor(story)
|
|
2088
|
+
})
|
|
2089
|
+
.catch(() => {})
|
|
2090
|
+
}
|
|
1061
2091
|
}
|
|
1062
2092
|
|
|
1063
2093
|
boot()
|