claude-code-kanban 7.12.0 → 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+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/lib/worktrees.js CHANGED
@@ -34,6 +34,10 @@ function fromGitFile(dir, text) {
34
34
  return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
35
35
  }
36
36
 
37
+ function pick(entry) {
38
+ return { repo: entry.repo, name: entry.name };
39
+ }
40
+
37
41
  function fromPathShape(dir) {
38
42
  const m = CLAUDE_WORKTREE_RE.exec(dir);
39
43
  return m ? { repo: m[1], name: m[2] } : null;
@@ -67,20 +71,34 @@ function createWorktreeStore({ load, save, read = readGitFile, now = Date.now, d
67
71
  });
68
72
  }
69
73
 
74
+ // Walks up from `dir` the way git discovers its repo, so a session started in a subfolder of a
75
+ // worktree resolves too. A saved ancestor answers for a worktree whose checkout is gone; the
76
+ // claude path shape answers for one that was never seen.
77
+ function lookUp(dir) {
78
+ for (let d = dir, up; ; d = up) {
79
+ try {
80
+ return fromGitFile(d, read(d));
81
+ } catch (e) {
82
+ // An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
83
+ // answer, and it costs one syscall instead of a stat followed by a read.
84
+ if (e.code !== 'ENOENT') return null;
85
+ }
86
+ const shaped = fromPathShape(d);
87
+ if (shaped) return shaped;
88
+ up = path.dirname(d);
89
+ if (up === d) return null;
90
+ const hit = hits.get(up);
91
+ if (hit) return pick(hit);
92
+ }
93
+ }
94
+
70
95
  function resolve(dir) {
71
96
  if (!dir) return null;
72
97
  const hit = hits.get(dir);
73
- if (hit) return { repo: hit.repo, name: hit.name };
98
+ if (hit) return pick(hit);
74
99
  if (misses.has(dir)) return null;
75
100
 
76
- let worktree = null;
77
- try {
78
- worktree = fromGitFile(dir, read(dir));
79
- } catch (e) {
80
- // An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
81
- // answer, and it costs one syscall instead of a stat followed by a read.
82
- if (e.code === 'ENOENT') worktree = fromPathShape(dir);
83
- }
101
+ const worktree = lookUp(dir);
84
102
 
85
103
  if (!worktree) {
86
104
  misses.add(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.12.0",
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' },
@@ -6433,8 +6439,8 @@ const SHORTCUT_TABS = [
6433
6439
  { keys: ['>'], label: 'Toggle sticky' },
6434
6440
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6435
6441
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
6436
- { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
6437
- { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
6442
+ { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
6443
+ { keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
6438
6444
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
6439
6445
  { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt; send review comments' },
6440
6446
  ],
@@ -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);
@@ -7548,6 +7556,18 @@ function shiftOnly(e, code) {
7548
7556
  return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
7549
7557
  }
7550
7558
 
7559
+ function toggleLastMessage() {
7560
+ if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
7561
+ closeMsgDetailModal();
7562
+ } else if (isWaitingFresh()) {
7563
+ msgDetailFollowLatest = true;
7564
+ showWaitingDetail();
7565
+ } else if (currentMessages.length) {
7566
+ msgDetailFollowLatest = true;
7567
+ showMsgDetail(currentMessages.length - 1);
7568
+ }
7569
+ }
7570
+
7551
7571
  function dismissSessionFromList(sid) {
7552
7572
  const prevIdx = selectedSessionIdx;
7553
7573
  if (!setSessionDismissed(sid, true)) return;
@@ -7813,6 +7833,12 @@ document.addEventListener('keydown', (e) => {
7813
7833
  }
7814
7834
  return;
7815
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
+ }
7816
7842
  if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
7817
7843
  e.preventDefault();
7818
7844
  return;
@@ -7843,16 +7869,7 @@ document.addEventListener('keydown', (e) => {
7843
7869
  }
7844
7870
  if (shiftOnly(e, 'KeyM')) {
7845
7871
  e.preventDefault();
7846
- const msgDetailModal = document.getElementById('msg-detail-modal');
7847
- if (msgDetailModal.classList.contains('visible')) {
7848
- closeMsgDetailModal();
7849
- } else if (isWaitingFresh()) {
7850
- msgDetailFollowLatest = true;
7851
- showWaitingDetail();
7852
- } else if (currentMessages.length) {
7853
- msgDetailFollowLatest = true;
7854
- showMsgDetail(currentMessages.length - 1);
7855
- }
7872
+ toggleLastMessage();
7856
7873
  return;
7857
7874
  }
7858
7875
  if (shiftOnly(e, 'KeyS')) {
@@ -8619,20 +8636,22 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
8619
8636
  ),
8620
8637
  );
8621
8638
 
8622
- // The sidebar and tab keys, so they work while a previewed page has focus. Matched by key where
8623
- // the board matches by key, so they follow the keyboard layout. A page that handles one first
8624
- // keeps it, and text fields keep every key.
8639
+ // The board keys a pane gets before the board's inPane return, so they work while a previewed page
8640
+ // has focus; a new key there needs a row here. Matched by key where the board matches by key, so
8641
+ // they follow the keyboard layout, and by code where it matches by code. A page that handles one
8642
+ // first keeps it, and text fields keep every key.
8625
8643
  const PANE_CLAIMS = [
8626
- ...['\\', '[', ']'].map((key) => ({ key, code: '' })),
8627
- ...['|', '{', '}'].map((key) => ({ shiftKey: true, key, code: '' })),
8644
+ ...['\\', '[', ']', 'ContextMenu', 'Escape'].map((key) => ({ key, code: '' })),
8645
+ ...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
8628
8646
  ...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
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}` })),
8629
8649
  ];
8630
8650
 
8631
- // Ctrl+D, Alt+Enter and Esc act on the show card (showCardKey); other pages keep them. Alt+W is in PANE_CLAIMS.
8651
+ // Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
8632
8652
  const SHOW_CLAIMS = [
8633
8653
  ...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
8634
8654
  { altKey: true, key: 'Enter', code: '' },
8635
- { key: 'Escape', code: '' },
8636
8655
  ];
8637
8656
 
8638
8657
  // Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
@@ -13233,6 +13252,7 @@ function setActivePaneId(sid, id) {
13233
13252
  if (getActivePaneId(sid) === id) return;
13234
13253
  paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
13235
13254
  notePairFocus(sid, id);
13255
+ noteGroupFocus(sid, id);
13236
13256
  }
13237
13257
 
13238
13258
  // {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
@@ -13293,18 +13313,75 @@ function neighborPair(sid, id, pairs) {
13293
13313
  return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
13294
13314
  }
13295
13315
 
13296
- // The strip in order, as {ids, pair}: a lone tab, or a pair at the place of its first tab.
13297
- 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) {
13298
13357
  const pairs = getPanePairs(sid);
13299
- const items = [];
13358
+ const groups = getPaneGroups(sid);
13359
+ const stops = [];
13360
+ const groupStops = new Map();
13361
+ const seen = new Set();
13300
13362
  for (const id of paneTabIds(sid)) {
13301
13363
  const pair = pairs.find((p) => splitSideOf(p, id)) || null;
13302
- if (!pair) items.push({ ids: [id], pair });
13303
- 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
+ }
13304
13375
  }
13305
- return items;
13376
+ return stops;
13306
13377
  }
13307
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
+
13308
13385
  function unpairPanes(sid, pair, keep) {
13309
13386
  setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
13310
13387
  if (keep) setActivePaneId(sid, keep);
@@ -13498,7 +13575,7 @@ function paneMenuLabel(pane, label) {
13498
13575
 
13499
13576
  function renderPaneTabs(sid, layout, split) {
13500
13577
  // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
13501
- if (paneDragId || paneRenameId) return;
13578
+ if (paneDragId || stripRename) return;
13502
13579
  const active = getActivePaneId(sid);
13503
13580
  const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
13504
13581
  const tab = (id, cls, inner, tip) =>
@@ -13527,9 +13604,17 @@ function renderPaneTabs(sid, layout, split) {
13527
13604
  pair
13528
13605
  ? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
13529
13606
  : tabHtml(ids[0]);
13530
- const [pinned, ...rest] = paneStripItems(sid).map(itemHtml);
13531
- const pin = pinned + (rest.length ? '<span class="pane-sep"></span>' : '');
13532
- 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('');
13533
13618
  // syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
13534
13619
  if (pin + html === paneTabsHtml) return;
13535
13620
  paneTabsHtml = pin + html;
@@ -13897,17 +13982,90 @@ function cyclePane(step) {
13897
13982
  return true;
13898
13983
  }
13899
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
+
13900
13997
  function cyclePaneStrip(step) {
13901
13998
  const sid = paneSessionId();
13902
13999
  if (!sid || wantsTerminal()) return false;
13903
14000
  const active = getActivePaneId(sid);
13904
- const items = paneStripItems(sid);
14001
+ const items = openStripItems(sid);
13905
14002
  const i = items.findIndex((it) => it.ids.includes(active));
13906
- const { ids, pair } = items[(i + step + items.length) % items.length];
13907
- selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
14003
+ selectStripItem(items[(i + step + items.length) % items.length]);
13908
14004
  return true;
13909
14005
  }
13910
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
+
13911
14069
  function isMenuKey(e) {
13912
14070
  return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
13913
14071
  }
@@ -14053,44 +14211,105 @@ async function deleteBoardTasks(onlyCompleted) {
14053
14211
  await refreshCurrentView();
14054
14212
  }
14055
14213
 
14056
- 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
+ }
14057
14237
 
14058
14238
  function beginPaneRename(id) {
14059
14239
  const pane = paneById(id);
14060
14240
  const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
14061
14241
  const title = tab?.querySelector('.pane-title');
14062
14242
  if (!pane || !title) return;
14063
- paneRenameId = id;
14064
14243
  tab.draggable = false;
14065
- const input = document.createElement('input');
14066
- input.className = 'pane-title-input';
14067
- input.value = pane.title;
14068
- input.setAttribute('aria-label', 'Pane title');
14069
- title.replaceWith(input);
14070
- input.focus();
14071
- 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
+ });
14072
14256
  }
14073
14257
 
14074
- async function endPaneRename(input, save) {
14075
- const id = paneRenameId;
14076
- if (!id) return;
14077
- paneRenameId = null;
14078
- paneTabsHtml = '';
14258
+ const PANE_GROUP_NAME_MAX = 40;
14259
+
14260
+ function beginPaneGroupRename(groupId) {
14079
14261
  const sid = paneSessionId();
14080
- const pane = paneById(id);
14081
- const title = input.value.trim();
14082
- if (save && sid && pane && title !== pane.title) {
14083
- const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, {
14084
- title,
14085
- });
14086
- if (res.error) showToast(res.error, 'error');
14087
- else applyPaneLayout(sid, res.layout);
14088
- }
14089
- 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
+ });
14090
14272
  }
14091
14273
 
14092
- const paneMenuItem = (act, label) =>
14093
- `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
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
+ );
14291
+ }
14292
+
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
+ }
14094
14313
 
14095
14314
  function openPaneMenu(x, y, id) {
14096
14315
  if (id === 'board') {
@@ -14123,6 +14342,7 @@ function openPaneMenu(x, y, id) {
14123
14342
  : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
14124
14343
  paneMenuItem('rename', 'Rename') +
14125
14344
  paneMenuItem('reload', 'Reload') +
14345
+ paneGroupMenuItems(pane.id) +
14126
14346
  paneMenuItem('close', 'Close pane'),
14127
14347
  );
14128
14348
  return true;
@@ -14165,6 +14385,11 @@ const PANE_MENU_ACTIONS = {
14165
14385
  rename: beginPaneRename,
14166
14386
  reload: reloadPane,
14167
14387
  close: closePane,
14388
+ group: (id, to) => groupPaneTab(id, to || null),
14389
+ groupRename: beginPaneGroupRename,
14390
+ groupToggle: togglePaneGroup,
14391
+ groupColor: colorPaneGroup,
14392
+ ungroup: ungroupPanes,
14168
14393
  deleteCompleted: () => deleteBoardTasks(true),
14169
14394
  deleteAll: () => deleteBoardTasks(false),
14170
14395
  };
@@ -14354,18 +14579,23 @@ function closePanePop() {
14354
14579
  }
14355
14580
 
14356
14581
  paneStrip.addEventListener('keydown', (e) => {
14357
- const input = e.target.closest?.('.pane-title-input');
14582
+ const input = e.target.closest?.('.strip-rename');
14358
14583
  if (input && (e.key === 'Enter' || e.key === 'Escape')) {
14359
14584
  e.preventDefault();
14360
- endPaneRename(input, e.key === 'Enter');
14585
+ endStripRename(input, e.key === 'Enter');
14361
14586
  }
14362
14587
  });
14363
14588
  paneStrip.addEventListener('focusout', (e) => {
14364
- const input = e.target.closest?.('.pane-title-input');
14365
- if (input) endPaneRename(input, true);
14589
+ const input = e.target.closest?.('.strip-rename');
14590
+ if (input) endStripRename(input, true);
14366
14591
  });
14367
14592
  paneStrip.addEventListener('click', (e) => {
14368
- 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
+ }
14369
14599
  const close = e.target.closest('[data-close]');
14370
14600
  if (close) {
14371
14601
  closePane(close.dataset.close);
@@ -14381,16 +14611,19 @@ paneStrip.addEventListener('auxclick', (e) => {
14381
14611
  if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
14382
14612
  });
14383
14613
  paneStrip.addEventListener('contextmenu', (e) => {
14614
+ const chip = e.target.closest('[data-group]');
14384
14615
  const tab = e.target.closest('.pane-tab');
14385
- if (!tab) return;
14616
+ if (!chip && !tab) return;
14386
14617
  e.preventDefault();
14387
- 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);
14388
14620
  });
14389
14621
  document.addEventListener('click', (e) => {
14390
14622
  const menu = document.getElementById('pane-menu');
14391
14623
  if (!menu) return;
14392
- const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
14393
- 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);
14394
14627
  if (act) closePaneMenu();
14395
14628
  else if (!menu.contains(e.target)) closePaneMenu(true);
14396
14629
  });
@@ -14466,15 +14699,18 @@ paneStrip.addEventListener('dragstart', (e) => {
14466
14699
  });
14467
14700
 
14468
14701
  // A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
14469
- // 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.
14470
14704
  function paneDropAction(e) {
14471
14705
  const sid = paneSessionId();
14472
14706
  const tab = e.target.closest?.('.pane-tab');
14707
+ const chip = e.target.closest?.('[data-group]');
14473
14708
  if (!sid || !paneDragId) return null;
14474
- if (tab) {
14709
+ if (chip || tab) {
14475
14710
  const [fixed, ...movable] = paneStripItems(sid);
14476
14711
  const itemOf = (id) => movable.find((it) => it.ids.includes(id));
14477
14712
  const from = itemOf(paneDragId);
14713
+ if (chip) return from ? { kind: 'group', sid, el: chip, group: chip.dataset.group } : null;
14478
14714
  const to = itemOf(tab.dataset.pane);
14479
14715
  if (from && to && from !== to) {
14480
14716
  const el = to.pair ? tab.closest('.pane-pair') : tab;
@@ -14488,9 +14724,11 @@ function paneDropAction(e) {
14488
14724
 
14489
14725
  function markPaneDrop(action) {
14490
14726
  paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
14491
- for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
14492
- el.classList.toggle('drop-before', el === action?.el && !action.after);
14493
- 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);
14494
14732
  }
14495
14733
  }
14496
14734
 
@@ -14517,11 +14755,15 @@ paneStrip.addEventListener('drop', (e) => {
14517
14755
  if (!action) return;
14518
14756
  e.preventDefault();
14519
14757
  const { sid, pair } = action;
14758
+ const id = paneDragId;
14520
14759
  const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
14521
- const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
14760
+ const keep = shown ? pair[otherSide(splitSideOf(pair, id))] : null;
14522
14761
  endPaneDrag();
14523
- if (action.kind === 'move') movePaneItem(action);
14524
- 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);
14525
14767
  });
14526
14768
  paneDrop.addEventListener('dragover', (e) => {
14527
14769
  if (!paneDragId) return;
@@ -14780,6 +15022,8 @@ function terminalShortcut(e, probe = false) {
14780
15022
  // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
14781
15023
  if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
14782
15024
  if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
15025
+ if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
15026
+ if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
14783
15027
  if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
14784
15028
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
14785
15029
  return () => openNewSession(null, e.code === 'KeyR');
package/public/style.css CHANGED
@@ -1058,10 +1058,14 @@ body.booting .boot-splash {
1058
1058
  position: absolute;
1059
1059
  inset: -1px;
1060
1060
  border-left: 3px solid var(--accent);
1061
- border-radius: inherit;
1062
1061
  pointer-events: none;
1063
1062
  }
1064
1063
 
1064
+ .session-item.unread::before,
1065
+ .session-item.permission-pending::before {
1066
+ border-radius: inherit;
1067
+ }
1068
+
1065
1069
  .session-item.permission-pending::before,
1066
1070
  .sp-row.permission-pending::before {
1067
1071
  border-left-color: var(--warning);
@@ -1200,7 +1204,6 @@ body.booting .boot-splash {
1200
1204
 
1201
1205
  /* The live-workflow bar takes the muted fill too: its card already carries a pulsing
1202
1206
  accent dot, and a second accent bar under it shouts. */
1203
- .session-item:not(.active) .progress-fill,
1204
1207
  .wf-live-bar .progress-fill {
1205
1208
  background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
1206
1209
  }
@@ -1212,6 +1215,14 @@ body.booting .boot-splash {
1212
1215
  font-variant-numeric: tabular-nums;
1213
1216
  }
1214
1217
 
1218
+ .session-progress .progress-bar {
1219
+ display: flex;
1220
+ }
1221
+
1222
+ .progress-fill.done {
1223
+ background: var(--success);
1224
+ }
1225
+
1215
1226
  .context-bar-labels {
1216
1227
  display: flex;
1217
1228
  justify-content: space-between;
@@ -4485,6 +4496,75 @@ body.show-resizing iframe {
4485
4496
  background: var(--border);
4486
4497
  }
4487
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
+
4488
4568
  .pane-strip.drop-hot {
4489
4569
  background: var(--accent-dim);
4490
4570
  }
@@ -9014,7 +9094,6 @@ select.form-input option:checked {
9014
9094
  align-items: baseline;
9015
9095
  gap: 8px;
9016
9096
  padding: 6px 8px;
9017
- border-radius: 4px;
9018
9097
  cursor: pointer;
9019
9098
  font-size: 12px;
9020
9099
  }
@@ -10438,4 +10517,32 @@ pre.mermaid[data-mermaid-error]::before {
10438
10517
  color: var(--text-primary);
10439
10518
  font-weight: 600;
10440
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
+ }
10441
10548
  /* #endregion */