claude-code-kanban 7.12.1 → 7.13.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/hub-app.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "icon": "columns",
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
- "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
8
+ "keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
9
9
  "plugin": {
10
10
  "marketplace": "plugin",
11
11
  "name": "claude-code-kanban",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.12.1",
3
+ "version": "7.13.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.8",
3
+ "version": "3.7.9",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }
@@ -17,7 +17,7 @@ The page is a document to read: its text and static exhibits are in the HTML, an
17
17
  1. `.kicker`, then `<title>` and `<h1>`: a name of 2 to 5 words. `.sub`: the question, in one line.
18
18
  2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
19
19
  3. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and at most 2 short paragraphs under it.
20
- 4. Close with what to do next and `.sources`.
20
+ 4. Close with what to do next and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
21
21
  3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit can be interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
22
22
  4. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
23
23
  5. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
@@ -62,6 +62,7 @@ Ready parts in the template's CSS; its body has one of each.
62
62
  | Callout | `div.callout` | A warning |
63
63
  | Steps | `ol.steps` | What to do next |
64
64
  | Tag, dot, legend | `span.tag`, `span.dot`, `.legend > span > i` | Labels and keys |
65
- | Sources | `div.sources` | Where each claim comes from |
65
+ | Sources | `.sources > ol > li#src-n > code + span`, then `p` | Where each claim comes from: one source per `li`, numbered in order (CSS prints `[n]`), the path or `a` link, and in `span` what it backs; a closing line, such as how estimates are marked, goes in the `p` |
66
+ | Citation | `a.cite[href="#src-n"]` holding `[n]` | An in-text marker that jumps to its source row |
66
67
 
67
68
  `data-tone` takes `accent`, `info`, `success`, `warning`, `danger` or `muted` on any block, legend keys included.
@@ -111,7 +111,8 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
111
111
  td.c { text-align: center; white-space: nowrap; }
112
112
  td.yes { --tone: var(--color-success); } td.no { --tone: var(--color-danger); } td.part { --tone: var(--color-warning); }
113
113
  td.yes, td.no, td.part { color: var(--tone); font-weight: 600; background: color-mix(in srgb, var(--tone) 13%, transparent); }
114
- td.yes::before { content: "● "; } td.part::before { content: "◐ "; } td.no::before { content: "○ "; }
114
+ td.yes::before, td.part::before, td.no::before { content: ""; display: inline-block; width: 8px; height: 8px; border: 1.5px solid; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
115
+ td.yes::before { background: currentColor; } td.part::before { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
115
116
  tr[data-tone] > td:first-child { box-shadow: inset 3px 0 0 var(--tone); background: color-mix(in srgb, var(--tone) 10%, transparent); }
116
117
 
117
118
  figure { margin: 0 0 20px; padding: 24px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-bg-subtle) radial-gradient(color-mix(in srgb, var(--color-text) 9%, transparent) 1px, transparent 1.5px) 0 0 / 18px 18px; }
@@ -131,9 +132,18 @@ ol.steps > li { counter-increment: step; position: relative; padding: 2px 0 20px
131
132
  ol.steps > li::before { content: counter(step); position: absolute; left: 0; top: 0; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font: 600 13px var(--font-mono); color: var(--color-bg); background: var(--color-accent); }
132
133
  ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 36px; bottom: 4px; width: 1px; background: var(--color-border); }
133
134
 
134
- .sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 12px/1.7 var(--font-mono); color: var(--color-text-muted); }
135
- .sources p { max-width: none; }
136
- .sources code { font-size: inherit; background: none; border: 0; padding: 0; }
135
+ .sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 13px/20px var(--font-sans); color: var(--color-text-muted); }
136
+ .sources::before { content: "Sources"; display: block; font: 500 12px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; margin: 0 0 10px; }
137
+ .sources ol { list-style: none; counter-reset: src; padding: 0; margin: 0; }
138
+ .sources li { counter-increment: src; position: relative; max-width: none; margin: 0; padding: 4px 8px 4px calc(5ch + 8px); border-radius: 6px; scroll-margin-top: 24px; }
139
+ .sources li::before { content: "[" counter(src) "]"; position: absolute; left: 8px; font: 12.5px/20px var(--font-mono); }
140
+ .sources li:target { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
141
+ .sources li > span { margin-left: 10px; }
142
+ .sources code { font-size: 12.5px; color: var(--color-text); background: none; border: 0; padding: 0; overflow-wrap: anywhere; }
143
+ .sources a { overflow-wrap: anywhere; }
144
+ .sources > p { max-width: none; margin: 12px 0 0; padding-left: 8px; }
145
+ a.cite { font: 500 .8em var(--font-mono); text-decoration: none; white-space: nowrap; }
146
+ a.cite:hover { text-decoration: underline; }
137
147
 
138
148
  @media (max-width: 600px) {
139
149
  main { padding-top: 36px; }
@@ -160,7 +170,7 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
160
170
  </div>
161
171
 
162
172
  <div class="kpis">
163
- <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes [1]</small></div>
173
+ <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes <a class="cite" href="#src-1">[1]</a></small></div>
164
174
  <div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
165
175
  </div>
166
176
 
@@ -180,7 +190,7 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
180
190
  <div class="card" data-tone="accent">
181
191
  <h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
182
192
  <p class="verdict">The verdict, in one line.</p>
183
- <p>What it is, in one sentence.</p>
193
+ <p>What it is, in one sentence. <a class="cite" href="#src-2">[2]</a></p>
184
194
  <pre>on 429: wait, then retry</pre>
185
195
  <ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
186
196
  </div>
@@ -194,7 +204,8 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
194
204
  <thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
195
205
  <tbody>
196
206
  <tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
197
- <tr><td><span class="dot">2</span>Option B</td><td class="c no">no</td><td class="num">300</td></tr>
207
+ <tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
208
+ <tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
198
209
  </tbody>
199
210
  </table></div>
200
211
  <ul class="points">
@@ -209,7 +220,14 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
209
220
  <ol class="steps"><li>First action.</li><li>Second action.</li></ol>
210
221
  </section>
211
222
 
212
- <div class="sources"><p>Sources: [1] <code>path/to/file.md</code> · [2] <a href="https://example.com">Title</a>. Estimates are marked as estimates.</p></div>
223
+ <div class="sources">
224
+ <ol>
225
+ <li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
226
+ <li id="src-2"><code>services/api/src/middleware/rate-limit/retry-after-handler.ts:42</code><span>the retry on 429</span></li>
227
+ <li id="src-3"><a href="https://example.com">Title of the page</a><span>line counts in the table</span></li>
228
+ </ol>
229
+ <p>Estimates are marked as estimates.</p>
230
+ </div>
213
231
  </main>
214
232
  </body>
215
233
  </html>
package/public/app.js CHANGED
@@ -3918,7 +3918,7 @@ function renderSessions() {
3918
3918
  const renderSessionCard = (session) => {
3919
3919
  if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
3920
3920
  const total = session.taskCount;
3921
- const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3921
+ const pct = (n) => Math.round((n / total) * 100);
3922
3922
  const isActive = session.id === currentSessionId && viewMode === 'session';
3923
3923
  const isLive = isSessionLive(session);
3924
3924
  const sessionName = sessionDisplayName(session);
@@ -3951,7 +3951,7 @@ function renderSessions() {
3951
3951
  const sid = escAttrJs(session.id);
3952
3952
  const progressHtml =
3953
3953
  total > 0
3954
- ? `<div class="session-progress" title="${escapeHtml(session.completed)} of ${total} tasks done"><div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div><span class="progress-text">${session.completed}/${total}</span></div>`
3954
+ ? `<div class="session-progress" title="${escapeHtml(`${session.pending} pending, ${session.inProgress} in progress, ${session.completed} completed`)}"><div class="progress-bar"><div class="progress-fill done" style="width: ${pct(session.completed)}%"></div>${session.inProgress > 0 ? `<div class="progress-fill wip" style="width: ${pct(session.inProgress)}%"></div>` : ''}</div><span class="progress-text">${session.completed + session.inProgress}/${total}</span></div>`
3955
3955
  : '';
3956
3956
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3957
3957
  return `
@@ -6391,6 +6391,12 @@ const SHORTCUT_TABS = [
6391
6391
  title: 'Tabs',
6392
6392
  rows: [
6393
6393
  { keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
6394
+ {
6395
+ keys: ['Ctrl', 'Alt', '1…9'],
6396
+ combo: true,
6397
+ yields: 'ctrl+alt+1',
6398
+ label: 'Go to tab or group N (the board is 1)',
6399
+ },
6394
6400
  { keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
6395
6401
  { keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
6396
6402
  { keys: ['{', '}'], label: 'Focus left / right side of a split' },
@@ -6542,6 +6548,8 @@ function termKbd() {
6542
6548
  function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
6543
6549
  const live = row.action && keyLabel(row.action);
6544
6550
  if (live) return live;
6551
+ // `yields`: the row's key gives way when the hub binds that combo.
6552
+ if (row.yields && hub.forwardCombos().includes(row.yields)) return [];
6545
6553
  if (!mac) return row.keys;
6546
6554
  if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
6547
6555
  return row.keys.map((k) => MAC_KEYS[k] || k);
@@ -7825,6 +7833,12 @@ document.addEventListener('keydown', (e) => {
7825
7833
  }
7826
7834
  return;
7827
7835
  }
7836
+ // The hub binds Ctrl+Alt+digit on macOS for its app keys.
7837
+ if (hubModDown(e) && /^Digit[1-9]$/.test(e.code) && !hub.forwards(e) && selectPaneByNumber(Number(e.code.slice(5)))) {
7838
+ e.preventDefault();
7839
+ focusActivePane();
7840
+ return;
7841
+ }
7828
7842
  if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
7829
7843
  e.preventDefault();
7830
7844
  return;
@@ -8631,6 +8645,7 @@ const PANE_CLAIMS = [
8631
8645
  ...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
8632
8646
  ...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
8633
8647
  ...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
8648
+ ...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
8634
8649
  ];
8635
8650
 
8636
8651
  // Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
@@ -13237,6 +13252,7 @@ function setActivePaneId(sid, id) {
13237
13252
  if (getActivePaneId(sid) === id) return;
13238
13253
  paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
13239
13254
  notePairFocus(sid, id);
13255
+ noteGroupFocus(sid, id);
13240
13256
  }
13241
13257
 
13242
13258
  // {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
@@ -13297,18 +13313,75 @@ function neighborPair(sid, id, pairs) {
13297
13313
  return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
13298
13314
  }
13299
13315
 
13300
- // The strip in order, as {ids, pair}: a lone tab, or a pair at the place of its first tab.
13301
- function paneStripItems(sid) {
13316
+ // {[sessionId]: [{id, name, color, ids, closed, last}]}; `ids` are pane ids, `last` the one shown
13317
+ // last. Like pairs, groups live in this browser tab only. A group shows at the place of its first
13318
+ // tab, so grouping leaves the server's order alone; a drag saves the order on screen.
13319
+ const PANE_GROUPS_KEY = 'pane-groups';
13320
+ const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
13321
+ let paneGroups = loadPaneState(PANE_GROUPS_KEY);
13322
+
13323
+ // Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
13324
+ function getPaneGroups(sid) {
13325
+ if (!paneGroups[sid]) return [];
13326
+ const live = new Set(paneLayout(sid).panes.map((p) => p.id));
13327
+ const taken = new Set();
13328
+ const groups = [];
13329
+ for (const g of paneGroups[sid]) {
13330
+ const ids = g.ids.filter((id) => live.has(id) && !taken.has(id));
13331
+ for (const id of ids) taken.add(id);
13332
+ const color = PANE_GROUP_COLORS.includes(g.color) ? g.color : PANE_GROUP_COLORS[0];
13333
+ if (ids.length) groups.push({ id: g.id, name: g.name || '', color, ids, closed: !!g.closed, last: g.last });
13334
+ }
13335
+ return groups;
13336
+ }
13337
+
13338
+ function setPaneGroups(sid, groups) {
13339
+ paneGroups = savePaneState(PANE_GROUPS_KEY, paneGroups, sid, groups.length ? groups : null);
13340
+ }
13341
+
13342
+ // A pair is in the group of either side, and the board is in none.
13343
+ function groupOfTabs(groups, ids) {
13344
+ return ids.includes('board') ? null : groups.find((g) => ids.some((t) => g.ids.includes(t))) || null;
13345
+ }
13346
+
13347
+ function noteGroupFocus(sid, id) {
13348
+ const pair = pairOf(sid, id);
13349
+ const group = groupOfTabs(getPaneGroups(sid), pair ? [pair.l, pair.r] : [id]);
13350
+ if (group && (group.closed || group.last !== id)) updatePaneGroup(sid, group.id, { closed: false, last: id });
13351
+ }
13352
+
13353
+ // The strip as the number keys count it, {group, items}: a group is one stop, open or collapsed.
13354
+ // An item is {ids, pair, group}: a lone tab, or a pair at the place of its first tab. A group's
13355
+ // items follow its first one.
13356
+ function paneStripStops(sid) {
13302
13357
  const pairs = getPanePairs(sid);
13303
- const items = [];
13358
+ const groups = getPaneGroups(sid);
13359
+ const stops = [];
13360
+ const groupStops = new Map();
13361
+ const seen = new Set();
13304
13362
  for (const id of paneTabIds(sid)) {
13305
13363
  const pair = pairs.find((p) => splitSideOf(p, id)) || null;
13306
- if (!pair) items.push({ ids: [id], pair });
13307
- else if (!items.some((it) => it.pair === pair)) items.push({ ids: [pair.l, pair.r], pair });
13364
+ if (pair && seen.has(pair)) continue;
13365
+ if (pair) seen.add(pair);
13366
+ const ids = pair ? [pair.l, pair.r] : [id];
13367
+ const group = groupOfTabs(groups, ids);
13368
+ const item = { ids, pair, group };
13369
+ const stop = group && groupStops.get(group);
13370
+ if (stop) stop.items.push(item);
13371
+ else {
13372
+ stops.push({ group, items: [item] });
13373
+ if (group) groupStops.set(group, stops.at(-1));
13374
+ }
13308
13375
  }
13309
- return items;
13376
+ return stops;
13310
13377
  }
13311
13378
 
13379
+ const paneStripItems = (sid) => paneStripStops(sid).flatMap((s) => s.items);
13380
+
13381
+ // The items a click or a key can reach: a collapsed group hides its tabs.
13382
+ const isOpenItem = (it) => !it.group?.closed;
13383
+ const openStripItems = (sid) => paneStripItems(sid).filter(isOpenItem);
13384
+
13312
13385
  function unpairPanes(sid, pair, keep) {
13313
13386
  setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
13314
13387
  if (keep) setActivePaneId(sid, keep);
@@ -13502,7 +13575,7 @@ function paneMenuLabel(pane, label) {
13502
13575
 
13503
13576
  function renderPaneTabs(sid, layout, split) {
13504
13577
  // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
13505
- if (paneDragId || paneRenameId) return;
13578
+ if (paneDragId || stripRename) return;
13506
13579
  const active = getActivePaneId(sid);
13507
13580
  const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
13508
13581
  const tab = (id, cls, inner, tip) =>
@@ -13531,9 +13604,17 @@ function renderPaneTabs(sid, layout, split) {
13531
13604
  pair
13532
13605
  ? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
13533
13606
  : tabHtml(ids[0]);
13534
- const [pinned, ...rest] = paneStripItems(sid).map(itemHtml);
13535
- const pin = pinned + (rest.length ? '<span class="pane-sep"></span>' : '');
13536
- const html = rest.join('');
13607
+ const groupHtml = (g, items) => {
13608
+ const n = items.flatMap((it) => it.ids).length;
13609
+ const tip = g.closed ? `Expand group (${n} tabs)` : 'Collapse group';
13610
+ const label =
13611
+ (g.name ? `<span>${escapeHtml(g.name)}</span>` : '') +
13612
+ (g.closed ? `<span class="pane-group-count">${n}</span>` : '');
13613
+ return `<div class="pane-group${g.closed ? ' closed' : ''}" style="${paneGroupStyle(g.color)}"><button class="pane-group-chip" data-group="${escapeHtml(g.id)}" title="${escapeHtml(tip)}">${label}</button>${g.closed ? '' : items.map(itemHtml).join('')}</div>`;
13614
+ };
13615
+ const [first, ...rest] = paneStripStops(sid);
13616
+ const pin = itemHtml(first.items[0]) + (rest.length ? '<span class="pane-sep"></span>' : '');
13617
+ const html = rest.map(({ group, items }) => (group ? groupHtml(group, items) : itemHtml(items[0]))).join('');
13537
13618
  // syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
13538
13619
  if (pin + html === paneTabsHtml) return;
13539
13620
  paneTabsHtml = pin + html;
@@ -13901,17 +13982,90 @@ function cyclePane(step) {
13901
13982
  return true;
13902
13983
  }
13903
13984
 
13985
+ function selectStripItem({ ids, pair }) {
13986
+ selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
13987
+ }
13988
+
13989
+ function selectPaneByNumber(n) {
13990
+ const sid = paneSessionId();
13991
+ if (!sid || wantsTerminal()) return false;
13992
+ const stop = paneStripStops(sid)[n - 1];
13993
+ if (stop) selectStripItem(stop.items.find((it) => it.ids.includes(stop.group?.last)) || stop.items[0]);
13994
+ return true;
13995
+ }
13996
+
13904
13997
  function cyclePaneStrip(step) {
13905
13998
  const sid = paneSessionId();
13906
13999
  if (!sid || wantsTerminal()) return false;
13907
14000
  const active = getActivePaneId(sid);
13908
- const items = paneStripItems(sid);
14001
+ const items = openStripItems(sid);
13909
14002
  const i = items.findIndex((it) => it.ids.includes(active));
13910
- const { ids, pair } = items[(i + step + items.length) % items.length];
13911
- selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
14003
+ selectStripItem(items[(i + step + items.length) % items.length]);
13912
14004
  return true;
13913
14005
  }
13914
14006
 
14007
+ function updatePaneGroup(sid, groupId, change) {
14008
+ const groups = getPaneGroups(sid);
14009
+ const group = groups.find((g) => g.id === groupId);
14010
+ if (!group) return;
14011
+ Object.assign(group, change);
14012
+ setPaneGroups(sid, groups);
14013
+ }
14014
+
14015
+ // `to` is a group id, 'new', or null to take the tab out of its group. A split moves as one.
14016
+ function groupPaneTab(id, to) {
14017
+ const sid = paneSessionId();
14018
+ const item = sid && paneStripItems(sid).find((it) => it.ids.includes(id));
14019
+ if (!item || item.ids.includes('board')) return;
14020
+ const groups = getPaneGroups(sid);
14021
+ for (const g of groups) g.ids = g.ids.filter((t) => !item.ids.includes(t));
14022
+ let target = groups.find((g) => g.id === to);
14023
+ if (to === 'new') {
14024
+ const used = new Set(groups.filter((g) => g.ids.length).map((g) => g.color));
14025
+ const color =
14026
+ PANE_GROUP_COLORS.find((c) => !used.has(c)) || PANE_GROUP_COLORS[groups.length % PANE_GROUP_COLORS.length];
14027
+ target = { id: `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, name: '', color, ids: [] };
14028
+ groups.push(target);
14029
+ }
14030
+ if (target) Object.assign(target, { ids: [...target.ids, ...item.ids], closed: false });
14031
+ setPaneGroups(sid, groups);
14032
+ syncPanes();
14033
+ if (to === 'new') beginPaneGroupRename(target.id);
14034
+ }
14035
+
14036
+ // A collapsed group cannot hold the tab on screen, so collapsing it moves to the next open item.
14037
+ function togglePaneGroup(groupId) {
14038
+ const sid = paneSessionId();
14039
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14040
+ if (!group) return;
14041
+ const closing = !group.closed;
14042
+ updatePaneGroup(sid, groupId, { closed: closing });
14043
+ const items = paneStripItems(sid);
14044
+ const i = items.findIndex((it) => it.ids.includes(getActivePaneId(sid)));
14045
+ if (!closing || items[i]?.group?.id !== groupId) {
14046
+ syncPanes();
14047
+ return;
14048
+ }
14049
+ selectStripItem(items.slice(i).find(isOpenItem) || items.slice(0, i).findLast(isOpenItem));
14050
+ }
14051
+
14052
+ function ungroupPanes(groupId) {
14053
+ const sid = paneSessionId();
14054
+ if (!sid) return;
14055
+ setPaneGroups(
14056
+ sid,
14057
+ getPaneGroups(sid).filter((g) => g.id !== groupId),
14058
+ );
14059
+ syncPanes();
14060
+ }
14061
+
14062
+ function colorPaneGroup(groupId, color) {
14063
+ const sid = paneSessionId();
14064
+ if (!sid) return;
14065
+ updatePaneGroup(sid, groupId, { color });
14066
+ syncPanes();
14067
+ }
14068
+
13915
14069
  function isMenuKey(e) {
13916
14070
  return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
13917
14071
  }
@@ -14057,44 +14211,105 @@ async function deleteBoardTasks(onlyCompleted) {
14057
14211
  await refreshCurrentView();
14058
14212
  }
14059
14213
 
14060
- let paneRenameId = null;
14214
+ // The save function of the tab or group name being edited.
14215
+ let stripRename = null;
14216
+
14217
+ function beginStripRename(el, { className, value, label, maxLength, save }) {
14218
+ stripRename = save;
14219
+ const input = document.createElement('input');
14220
+ input.className = `strip-rename ${className}`;
14221
+ input.value = value;
14222
+ if (maxLength) input.maxLength = maxLength;
14223
+ input.setAttribute('aria-label', label);
14224
+ el.replaceWith(input);
14225
+ input.focus();
14226
+ input.select();
14227
+ }
14228
+
14229
+ async function endStripRename(input, save) {
14230
+ const onSave = stripRename;
14231
+ if (!onSave) return;
14232
+ stripRename = null;
14233
+ paneTabsHtml = '';
14234
+ if (save) await onSave(input.value.trim());
14235
+ syncPanes();
14236
+ }
14061
14237
 
14062
14238
  function beginPaneRename(id) {
14063
14239
  const pane = paneById(id);
14064
14240
  const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
14065
14241
  const title = tab?.querySelector('.pane-title');
14066
14242
  if (!pane || !title) return;
14067
- paneRenameId = id;
14068
14243
  tab.draggable = false;
14069
- const input = document.createElement('input');
14070
- input.className = 'pane-title-input';
14071
- input.value = pane.title;
14072
- input.setAttribute('aria-label', 'Pane title');
14073
- title.replaceWith(input);
14074
- input.focus();
14075
- input.select();
14244
+ beginStripRename(title, {
14245
+ className: 'pane-title-input',
14246
+ value: pane.title,
14247
+ label: 'Pane title',
14248
+ save: async (text) => {
14249
+ const sid = paneSessionId();
14250
+ if (!sid || !paneById(id) || text === paneById(id).title) return;
14251
+ const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { title: text });
14252
+ if (res.error) showToast(res.error, 'error');
14253
+ else applyPaneLayout(sid, res.layout);
14254
+ },
14255
+ });
14076
14256
  }
14077
14257
 
14078
- async function endPaneRename(input, save) {
14079
- const id = paneRenameId;
14080
- if (!id) return;
14081
- paneRenameId = null;
14082
- paneTabsHtml = '';
14258
+ const PANE_GROUP_NAME_MAX = 40;
14259
+
14260
+ function beginPaneGroupRename(groupId) {
14083
14261
  const sid = paneSessionId();
14084
- const pane = paneById(id);
14085
- const title = input.value.trim();
14086
- if (save && sid && pane && title !== pane.title) {
14087
- const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, {
14088
- title,
14089
- });
14090
- if (res.error) showToast(res.error, 'error');
14091
- else applyPaneLayout(sid, res.layout);
14092
- }
14093
- syncPanes();
14262
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14263
+ const chip = paneStrip.querySelector(`[data-group="${CSS.escape(groupId)}"]`);
14264
+ if (!group || !chip) return;
14265
+ beginStripRename(chip, {
14266
+ className: 'pane-group-input',
14267
+ value: group.name,
14268
+ label: 'Group name',
14269
+ maxLength: PANE_GROUP_NAME_MAX,
14270
+ save: (name) => updatePaneGroup(sid, groupId, { name: name.slice(0, PANE_GROUP_NAME_MAX) }),
14271
+ });
14272
+ }
14273
+
14274
+ const paneMenuItem = (act, label, arg) =>
14275
+ `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}"${arg == null ? '' : ` data-arg="${escapeHtml(arg)}"`}>${label}</button>`;
14276
+ const paneGroupStyle = (color) => escapeHtml(`--g: var(--owner-${color})`);
14277
+ const paneGroupDot = (g) => `<span class="pane-menu-dot" style="${paneGroupStyle(g.color)}"></span>`;
14278
+
14279
+ function paneGroupMenuItems(id) {
14280
+ const sid = paneSessionId();
14281
+ const item = paneStripItems(sid).find((it) => it.ids.includes(id));
14282
+ if (!item || item.ids.includes('board')) return '';
14283
+ const others = getPaneGroups(sid).filter((g) => g.id !== item.group?.id);
14284
+ return (
14285
+ paneMenuItem('group', 'Add to new group', 'new') +
14286
+ others
14287
+ .map((g) => paneMenuItem('group', `Add to ${paneGroupDot(g)}${escapeHtml(g.name || 'unnamed group')}`, g.id))
14288
+ .join('') +
14289
+ (item.group ? paneMenuItem('group', 'Remove from group', '') : '')
14290
+ );
14094
14291
  }
14095
14292
 
14096
- const paneMenuItem = (act, label) =>
14097
- `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
14293
+ function openPaneGroupMenu(x, y, groupId) {
14294
+ const sid = paneSessionId();
14295
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14296
+ if (!group) return;
14297
+ const swatches = PANE_GROUP_COLORS.map(
14298
+ (c) =>
14299
+ `<button class="pane-menu-item pane-menu-swatch${c === group.color ? ' on' : ''}" role="menuitemradio" aria-checked="${escapeHtml(String(c === group.color))}" aria-label="${escapeHtml(c)}" title="${escapeHtml(c)}" data-pane-act="groupColor" data-arg="${escapeHtml(c)}" style="${paneGroupStyle(c)}"></button>`,
14300
+ ).join('');
14301
+ showPaneMenu(
14302
+ x,
14303
+ y,
14304
+ group.id,
14305
+ 'Group actions',
14306
+ `<div class="pane-menu-label">${escapeHtml(group.name || 'Unnamed group')}</div>` +
14307
+ paneMenuItem('groupRename', 'Rename') +
14308
+ paneMenuItem('groupToggle', group.closed ? 'Expand' : 'Collapse') +
14309
+ `<div class="pane-menu-swatches">${swatches}</div>` +
14310
+ paneMenuItem('ungroup', 'Ungroup'),
14311
+ );
14312
+ }
14098
14313
 
14099
14314
  function openPaneMenu(x, y, id) {
14100
14315
  if (id === 'board') {
@@ -14127,6 +14342,7 @@ function openPaneMenu(x, y, id) {
14127
14342
  : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
14128
14343
  paneMenuItem('rename', 'Rename') +
14129
14344
  paneMenuItem('reload', 'Reload') +
14345
+ paneGroupMenuItems(pane.id) +
14130
14346
  paneMenuItem('close', 'Close pane'),
14131
14347
  );
14132
14348
  return true;
@@ -14169,6 +14385,11 @@ const PANE_MENU_ACTIONS = {
14169
14385
  rename: beginPaneRename,
14170
14386
  reload: reloadPane,
14171
14387
  close: closePane,
14388
+ group: (id, to) => groupPaneTab(id, to || null),
14389
+ groupRename: beginPaneGroupRename,
14390
+ groupToggle: togglePaneGroup,
14391
+ groupColor: colorPaneGroup,
14392
+ ungroup: ungroupPanes,
14172
14393
  deleteCompleted: () => deleteBoardTasks(true),
14173
14394
  deleteAll: () => deleteBoardTasks(false),
14174
14395
  };
@@ -14358,18 +14579,23 @@ function closePanePop() {
14358
14579
  }
14359
14580
 
14360
14581
  paneStrip.addEventListener('keydown', (e) => {
14361
- const input = e.target.closest?.('.pane-title-input');
14582
+ const input = e.target.closest?.('.strip-rename');
14362
14583
  if (input && (e.key === 'Enter' || e.key === 'Escape')) {
14363
14584
  e.preventDefault();
14364
- endPaneRename(input, e.key === 'Enter');
14585
+ endStripRename(input, e.key === 'Enter');
14365
14586
  }
14366
14587
  });
14367
14588
  paneStrip.addEventListener('focusout', (e) => {
14368
- const input = e.target.closest?.('.pane-title-input');
14369
- if (input) endPaneRename(input, true);
14589
+ const input = e.target.closest?.('.strip-rename');
14590
+ if (input) endStripRename(input, true);
14370
14591
  });
14371
14592
  paneStrip.addEventListener('click', (e) => {
14372
- if (e.target.closest('.pane-title-input')) return;
14593
+ if (e.target.closest('.strip-rename')) return;
14594
+ const chip = e.target.closest('[data-group]');
14595
+ if (chip) {
14596
+ togglePaneGroup(chip.dataset.group);
14597
+ return;
14598
+ }
14373
14599
  const close = e.target.closest('[data-close]');
14374
14600
  if (close) {
14375
14601
  closePane(close.dataset.close);
@@ -14385,16 +14611,19 @@ paneStrip.addEventListener('auxclick', (e) => {
14385
14611
  if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
14386
14612
  });
14387
14613
  paneStrip.addEventListener('contextmenu', (e) => {
14614
+ const chip = e.target.closest('[data-group]');
14388
14615
  const tab = e.target.closest('.pane-tab');
14389
- if (!tab) return;
14616
+ if (!chip && !tab) return;
14390
14617
  e.preventDefault();
14391
- openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
14618
+ if (chip) openPaneGroupMenu(e.clientX, e.clientY, chip.dataset.group);
14619
+ else openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
14392
14620
  });
14393
14621
  document.addEventListener('click', (e) => {
14394
14622
  const menu = document.getElementById('pane-menu');
14395
14623
  if (!menu) return;
14396
- const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
14397
- if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
14624
+ const item = e.target.closest('.pane-menu-item');
14625
+ const act = item?.dataset.paneAct;
14626
+ if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane, item.dataset.arg);
14398
14627
  if (act) closePaneMenu();
14399
14628
  else if (!menu.contains(e.target)) closePaneMenu(true);
14400
14629
  });
@@ -14470,15 +14699,18 @@ paneStrip.addEventListener('dragstart', (e) => {
14470
14699
  });
14471
14700
 
14472
14701
  // A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
14473
- // holds the board and stays put. Elsewhere on the strip, a split tab's drop ends its split.
14702
+ // holds the board and stays put. A moved item joins the group it lands in, and a drop on a
14703
+ // group's chip adds it to that group. Elsewhere on the strip, a split tab's drop ends its split.
14474
14704
  function paneDropAction(e) {
14475
14705
  const sid = paneSessionId();
14476
14706
  const tab = e.target.closest?.('.pane-tab');
14707
+ const chip = e.target.closest?.('[data-group]');
14477
14708
  if (!sid || !paneDragId) return null;
14478
- if (tab) {
14709
+ if (chip || tab) {
14479
14710
  const [fixed, ...movable] = paneStripItems(sid);
14480
14711
  const itemOf = (id) => movable.find((it) => it.ids.includes(id));
14481
14712
  const from = itemOf(paneDragId);
14713
+ if (chip) return from ? { kind: 'group', sid, el: chip, group: chip.dataset.group } : null;
14482
14714
  const to = itemOf(tab.dataset.pane);
14483
14715
  if (from && to && from !== to) {
14484
14716
  const el = to.pair ? tab.closest('.pane-pair') : tab;
@@ -14492,9 +14724,11 @@ function paneDropAction(e) {
14492
14724
 
14493
14725
  function markPaneDrop(action) {
14494
14726
  paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
14495
- for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
14496
- el.classList.toggle('drop-before', el === action?.el && !action.after);
14497
- el.classList.toggle('drop-after', el === action?.el && action.after);
14727
+ for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair, .pane-group-chip')) {
14728
+ const on = el === action?.el;
14729
+ el.classList.toggle('drop-in', on && action.kind === 'group');
14730
+ el.classList.toggle('drop-before', on && action.kind === 'move' && !action.after);
14731
+ el.classList.toggle('drop-after', on && action.kind === 'move' && action.after);
14498
14732
  }
14499
14733
  }
14500
14734
 
@@ -14521,11 +14755,15 @@ paneStrip.addEventListener('drop', (e) => {
14521
14755
  if (!action) return;
14522
14756
  e.preventDefault();
14523
14757
  const { sid, pair } = action;
14758
+ const id = paneDragId;
14524
14759
  const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
14525
- const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
14760
+ const keep = shown ? pair[otherSide(splitSideOf(pair, id))] : null;
14526
14761
  endPaneDrag();
14527
- if (action.kind === 'move') movePaneItem(action);
14528
- else unpairPanes(sid, pair, keep);
14762
+ if (action.kind === 'group') groupPaneTab(id, action.group);
14763
+ else if (action.kind === 'move') {
14764
+ if (action.from.group?.id !== action.to.group?.id) groupPaneTab(id, action.to.group?.id ?? null);
14765
+ movePaneItem(action);
14766
+ } else unpairPanes(sid, pair, keep);
14529
14767
  });
14530
14768
  paneDrop.addEventListener('dragover', (e) => {
14531
14769
  if (!paneDragId) return;
package/public/style.css CHANGED
@@ -1204,7 +1204,6 @@ body.booting .boot-splash {
1204
1204
 
1205
1205
  /* The live-workflow bar takes the muted fill too: its card already carries a pulsing
1206
1206
  accent dot, and a second accent bar under it shouts. */
1207
- .session-item:not(.active) .progress-fill,
1208
1207
  .wf-live-bar .progress-fill {
1209
1208
  background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
1210
1209
  }
@@ -1216,6 +1215,14 @@ body.booting .boot-splash {
1216
1215
  font-variant-numeric: tabular-nums;
1217
1216
  }
1218
1217
 
1218
+ .session-progress .progress-bar {
1219
+ display: flex;
1220
+ }
1221
+
1222
+ .progress-fill.done {
1223
+ background: var(--success);
1224
+ }
1225
+
1219
1226
  .context-bar-labels {
1220
1227
  display: flex;
1221
1228
  justify-content: space-between;
@@ -4489,6 +4496,75 @@ body.show-resizing iframe {
4489
4496
  background: var(--border);
4490
4497
  }
4491
4498
 
4499
+ .pane-group {
4500
+ position: relative;
4501
+ display: flex;
4502
+ gap: 2px;
4503
+ min-width: 0;
4504
+ }
4505
+
4506
+ /* Over the tabs, so a tab's own background does not hide it. */
4507
+ .pane-group:not(.closed)::after {
4508
+ content: '';
4509
+ position: absolute;
4510
+ inset: auto 0 1px;
4511
+ z-index: 1;
4512
+ height: 2px;
4513
+ background: var(--g);
4514
+ pointer-events: none;
4515
+ }
4516
+
4517
+ .pane-group-chip,
4518
+ .pane-group-input {
4519
+ flex: none;
4520
+ align-self: center;
4521
+ height: 18px;
4522
+ margin: 0 2px;
4523
+ padding: 0 8px;
4524
+ border: 0;
4525
+ border-radius: 6px;
4526
+ font-family: var(--font-mono);
4527
+ font-size: 11px;
4528
+ font-weight: 600;
4529
+ letter-spacing: 0.3px;
4530
+ }
4531
+
4532
+ .pane-group-chip {
4533
+ min-width: 14px;
4534
+ max-width: 140px;
4535
+ overflow: hidden;
4536
+ text-overflow: ellipsis;
4537
+ white-space: nowrap;
4538
+ background: var(--g);
4539
+ color: var(--bg-deep);
4540
+ cursor: pointer;
4541
+ }
4542
+
4543
+ .pane-group-chip:hover {
4544
+ filter: brightness(1.12);
4545
+ }
4546
+
4547
+ .pane-group-count {
4548
+ font-weight: 400;
4549
+ opacity: 0.75;
4550
+ }
4551
+
4552
+ .pane-group-count:not(:first-child) {
4553
+ margin-left: 6px;
4554
+ }
4555
+
4556
+ .pane-group-chip.drop-in {
4557
+ box-shadow: 0 0 0 2px var(--accent);
4558
+ }
4559
+
4560
+ .pane-group-input {
4561
+ width: 110px;
4562
+ color: var(--text-primary);
4563
+ background: var(--bg-deep);
4564
+ box-shadow: inset 0 0 0 1px var(--g);
4565
+ outline: none;
4566
+ }
4567
+
4492
4568
  .pane-strip.drop-hot {
4493
4569
  background: var(--accent-dim);
4494
4570
  }
@@ -10441,4 +10517,32 @@ pre.mermaid[data-mermaid-error]::before {
10441
10517
  color: var(--text-primary);
10442
10518
  font-weight: 600;
10443
10519
  }
10520
+ .pane-menu-dot {
10521
+ flex: none;
10522
+ width: 8px;
10523
+ height: 8px;
10524
+ border-radius: 50%;
10525
+ background: var(--g);
10526
+ }
10527
+ .pane-menu-swatches {
10528
+ display: flex;
10529
+ gap: 4px;
10530
+ padding: 4px 8px;
10531
+ }
10532
+ .pane-menu-item.pane-menu-swatch {
10533
+ flex: none;
10534
+ width: 16px;
10535
+ height: 16px;
10536
+ padding: 0;
10537
+ border-radius: 50%;
10538
+ background: var(--g);
10539
+ }
10540
+ .pane-menu-item.pane-menu-swatch:hover,
10541
+ .pane-menu-item.pane-menu-swatch:focus-visible,
10542
+ .pane-menu-swatch.on {
10543
+ background: var(--g);
10544
+ box-shadow:
10545
+ 0 0 0 2px var(--bg-elevated),
10546
+ 0 0 0 3px var(--text-primary);
10547
+ }
10444
10548
  /* #endregion */