claude-code-kanban 4.30.0 → 4.31.1

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/README.md CHANGED
@@ -59,7 +59,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
59
59
  - **Session log.** Every prompt, reply and tool call in order (<kbd>Shift</kbd>+<kbd>L</kbd>). Follow the newest message with <kbd>Shift</kbd>+<kbd>M</kbd>, and pin the messages that matter. [Session log and details](https://nikiforovall.blog/claude-code-kanban/guides/session-details/)
60
60
  - **Subagents.** The agents log lists each subagent with its model, status and run time. Open one to read its prompt and response. Team sessions get colored owner badges and an owner filter. [Subagents](https://nikiforovall.blog/claude-code-kanban/guides/subagents/)
61
61
  - **Session info and tool stats.** Model, branch, context window use, cost, and which tools ran. The sidebar footer shows your 5-hour and 7-day rate limit use.
62
- - **Zen mode.** <kbd>Shift</kbd>+<kbd>Z</kbd> shows only the current session in the sidebar, with its context use, scratchpad folder and linked documents.
62
+ - **Zen mode.** <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> shows only the current session in the sidebar, with its context use, scratchpad folder and linked documents.
63
63
 
64
64
  <picture>
65
65
  <source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-02-subagent-preview-dark.webp">
@@ -88,7 +88,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
88
88
 
89
89
  - **Session groups.** Drag sessions and projects from different folders into one named group in the sidebar. [Session groups](https://nikiforovall.blog/claude-code-kanban/guides/session-groups/)
90
90
  - **Filters, search and pins.** Filter by project and activity, search across sessions and tasks, pin a session or make it sticky. <kbd>Shift</kbd>+<kbd>P</kbd> opens the session picker.
91
- - **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files to a session to preview them in one click.
91
+ - **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files or web URLs to a session to open them in one click.
92
92
  - **Themes.** 17 color themes, each in light and dark. <kbd>T</kbd> switches the mode.
93
93
  - **Keyboard first.** Arrow keys move through tasks, and <kbd>Tab</kbd> moves between the sidebar and the board. Press <kbd>?</kbd> for the full list. [Keyboard shortcuts](https://nikiforovall.blog/claude-code-kanban/reference/keyboard-shortcuts/)
94
94
 
package/cli.js CHANGED
@@ -2,25 +2,26 @@ const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const { getClaudeDir, displayPath } = require('./lib/claude-dir');
4
4
  const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
5
+ const { linkUrl } = require('./public/link-url');
5
6
  // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
6
7
  const COMMANDS = {
7
8
  'preview-doc': {
8
9
  summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
9
- usage: 'claude-code-kanban preview-doc <file.md|file.html> [--session <id>]',
10
+ usage: 'claude-code-kanban preview-doc <file.md|file.html|url> [--session <id>]',
10
11
  flags: {
11
- '--session <id>': 'Switch focused session in the browser (does not link the file)',
12
+ '--session <id>': 'Switch focused session in the browser (does not link the file). Required for a URL.',
12
13
  },
13
- notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir.',
14
+ notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
14
15
  examples: [
15
16
  'claude-code-kanban preview-doc ./notes.md --session $CLAUDE_SESSION_ID',
16
17
  ],
17
18
  run: runPreviewCli,
18
19
  },
19
20
  'link-doc': {
20
- summary: 'Link a file to a session in the sidebar without opening the preview modal',
21
- usage: 'claude-code-kanban link-doc <file> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
21
+ summary: 'Link a file or URL to a session in the sidebar without opening the preview modal',
22
+ usage: 'claude-code-kanban link-doc <file|url> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
22
23
  flags: {
23
- '<file>': 'Any file type; one the preview cannot render opens in the editor',
24
+ '<file|url>': 'Any file type; one the preview cannot render opens in the editor. An http(s) URL opens in a new tab.',
24
25
  '--session <id>': 'Session to link the file to (required unless $PREVIEW_SESSION is set); full id or unique prefix',
25
26
  '--unlink': 'Remove the link instead of adding it (the file need not exist)',
26
27
  '--list': 'Print the docs the server holds for the session',
@@ -29,6 +30,7 @@ const COMMANDS = {
29
30
  notes: 'The server keeps the link, so it shows when a browser tab opens later.',
30
31
  examples: [
31
32
  'claude-code-kanban link-doc ./design.md --session $CLAUDE_SESSION_ID',
33
+ 'claude-code-kanban link-doc https://github.com/org/repo/pull/12 --session $CLAUDE_SESSION_ID',
32
34
  'claude-code-kanban link-doc --list --session $CLAUDE_SESSION_ID',
33
35
  ],
34
36
  run: runLinkDocCli,
@@ -464,6 +466,8 @@ async function runPreviewCli(args) {
464
466
  return 1;
465
467
  }
466
468
  const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
469
+ const url = linkUrl(filePathArg);
470
+ if (url) return previewUrlCli(url, sessionId);
467
471
  const abs = path.resolve(filePathArg);
468
472
  try {
469
473
  if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
@@ -472,6 +476,27 @@ async function runPreviewCli(args) {
472
476
  } catch (e) { reportCliError(e); return 1; }
473
477
  }
474
478
 
479
+ // The modal cannot show a web page, so a URL is linked instead and the tab on screen
480
+ // offers to open it.
481
+ async function previewUrlCli(url, sessionArg) {
482
+ const entry = COMMANDS['preview-doc'];
483
+ if (!sessionArg) return usageError(entry, '--session is required for a URL: it is linked to the session.');
484
+ const resolved = await resolveSessionByIdOrPrefix(sessionArg);
485
+ if (!resolved) return 1;
486
+ return postDocLink(url, resolved.id, { open: true });
487
+ }
488
+
489
+ async function postDocLink(target, sessionId, { unlink = false, open = false } = {}) {
490
+ try {
491
+ const out = await cliPostJson('/api/document/link', { path: target, sessionId, unlink, open }, 'Link');
492
+ if (!out) return 1;
493
+ const what = linkUrl(target) ? 'URL' : 'Document';
494
+ console.log(`${what} ${unlink ? 'unlinked from' : 'linked to'} session ${sessionId.slice(0, 8)}: ${out.path}`);
495
+ if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
496
+ return 0;
497
+ } catch (e) { reportCliError(e); return 1; }
498
+ }
499
+
475
500
  async function runLinkDocCli(args) {
476
501
  const entry = COMMANDS['link-doc'];
477
502
  const [filePathArg] = positionals(args, ['--session']);
@@ -487,14 +512,7 @@ async function runLinkDocCli(args) {
487
512
  const resolved = await resolveSessionByIdOrPrefix(sessionArg);
488
513
  if (!resolved) return 1;
489
514
  if (list) return printLinkedDocs(resolved.id, args.includes('--json'));
490
- const abs = path.resolve(filePathArg);
491
- try {
492
- const out = await cliPostJson('/api/document/link', { path: abs, sessionId: resolved.id, unlink }, 'Link');
493
- if (!out) return 1;
494
- console.log(`Document ${unlink ? 'unlinked from' : 'linked to'} session ${resolved.id.slice(0, 8)}: ${abs}`);
495
- if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
496
- return 0;
497
- } catch (e) { reportCliError(e); return 1; }
515
+ return postDocLink(linkUrl(filePathArg) || path.resolve(filePathArg), resolved.id, { unlink });
498
516
  }
499
517
 
500
518
  async function printLinkedDocs(sessionId, asJson) {
package/install.js CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const readline = require('node:readline');
6
6
  const { execSync } = require('node:child_process');
7
7
  const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
8
+ const { PLUGIN_ID } = require('./lib/plugin-status');
8
9
 
9
10
  const CLAUDE_DIR = getClaudeDir();
10
11
  const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
@@ -128,7 +129,7 @@ async function runInstall({ pluginOnly = false } = {}) {
128
129
  const upd = runCLI('claude plugin marketplace update claude-code-kanban');
129
130
  if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
130
131
 
131
- const inst = runCLI('claude plugin install claude-code-kanban@claude-code-kanban', ['already installed', 'already exists']);
132
+ const inst = runCLI(`claude plugin install ${PLUGIN_ID}`,['already installed', 'already exists']);
132
133
  const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
133
134
  if (inst.ok) {
134
135
  console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
@@ -1,7 +1,9 @@
1
- // Server copy of each session's linked docs. The browser keeps its own list in localStorage
1
+ // Server copy of each session's linked docs: file paths and http(s) URLs. The browser keeps its own list in localStorage
2
2
  // and merges this one in when it connects, so a `link-doc` sent while no tab is open is not lost.
3
3
  // Newest first, capped like the browser list.
4
4
 
5
+ const { linkUrl } = require('../public/link-url');
6
+
5
7
  const MAX_PER_SESSION = 20;
6
8
 
7
9
  // Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
@@ -53,4 +55,4 @@ function createLinkedDocStore({ load, save }) {
53
55
  return { link, unlink, get, all };
54
56
  }
55
57
 
56
- module.exports = { createLinkedDocStore, canonicalPath, MAX_PER_SESSION };
58
+ module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_PER_SESSION };
@@ -0,0 +1,38 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { isDefaultClaudeDir, displayPath } = require('./claude-dir');
4
+
5
+ const PLUGIN_ID = 'claude-code-kanban@claude-code-kanban';
6
+ const BUNDLED_MANIFEST = path.join(__dirname, '..', 'plugin', 'plugins', 'claude-code-kanban', '.claude-plugin', 'plugin.json');
7
+
8
+ function readJson(file) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ // The claude CLI records installs in <config dir>/plugins/installed_plugins.json, one entry per scope.
17
+ // A user-scope entry wins, because install.js installs at user scope.
18
+ function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
19
+ const bundled = readJson(bundledManifest)?.version ?? null;
20
+ const installs = readJson(path.join(claudeDir, 'plugins', 'installed_plugins.json'))?.plugins?.[PLUGIN_ID];
21
+ const entries = Array.isArray(installs) ? installs : [];
22
+ const entry = entries.find((e) => e?.scope === 'user') ?? entries[0];
23
+ const installed = typeof entry?.version === 'string' ? entry.version : null;
24
+ const enabled = readJson(path.join(claudeDir, 'settings.json'))?.enabledPlugins?.[PLUGIN_ID] !== false;
25
+ let state = 'ok';
26
+ if (!installed) state = 'missing';
27
+ else if (!enabled) state = 'disabled';
28
+ else if (bundled && installed !== bundled) state = 'mismatch';
29
+ return { bundled, installed, state, configDir: displayPath(claudeDir), installCommand: installCommand(claudeDir) };
30
+ }
31
+
32
+ // --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
33
+ function installCommand(claudeDir) {
34
+ const base = 'claude-code-kanban --install --plugin-only';
35
+ return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
36
+ }
37
+
38
+ module.exports = { pluginStatus, installCommand, PLUGIN_ID, BUNDLED_MANIFEST };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.30.0",
3
+ "version": "4.31.1",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -24,7 +24,7 @@ claude-code-kanban help <command> <subcommand> # flags and examples
24
24
  | `open` (or none) | `session open ${CLAUDE_SESSION_ID}` |
25
25
  | `pin` / `unpin` | `session pin ${CLAUDE_SESSION_ID}` (`--sticky`, `--unpin`) |
26
26
  | `preview` | `preview-doc <file> --session ${CLAUDE_SESSION_ID}` — opens a modal on the user's screen |
27
- | `link` | `link-doc <file> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working |
27
+ | `link` | `link-doc <file\|url> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working. An http(s) URL (a PR, an artifact) opens in a new tab |
28
28
  | `list` / `search` | `session list`, `session search <text>` |
29
29
  | `view` / `peek` | `session view <id>`, `session peek <id>` |
30
30
  | tasks, projects | `task list`, `project list` |
package/public/app.js CHANGED
@@ -749,6 +749,34 @@ function toggleMessagePanel() {
749
749
  updateUrl();
750
750
  }
751
751
 
752
+ function applyMessagePanelFull(on) {
753
+ document.getElementById('message-panel').classList.toggle('full-width', on);
754
+ const btn = document.getElementById('message-panel-full-btn');
755
+ btn.classList.toggle('active', on);
756
+ btn.setAttribute('aria-pressed', String(on));
757
+ const label = on ? 'Restore width' : 'Expand to full width';
758
+ btn.title = label;
759
+ btn.setAttribute('aria-label', label);
760
+ }
761
+
762
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
763
+ function toggleMessagePanelFull() {
764
+ const on = !document.getElementById('message-panel').classList.contains('full-width');
765
+ store.setItem('message-panel-full', on);
766
+ applyMessagePanelFull(on);
767
+ }
768
+
769
+ function initMessagePanelFull() {
770
+ const panel = document.getElementById('message-panel');
771
+ const sidebar = document.querySelector('.sidebar');
772
+ if (sidebar) {
773
+ const sync = () => panel.style.setProperty('--sidebar-edge', `${sidebar.getBoundingClientRect().right}px`);
774
+ new ResizeObserver(sync).observe(sidebar);
775
+ sync();
776
+ }
777
+ applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
778
+ }
779
+
752
780
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
753
781
  async function openSessionWithBookmarks(sessionId) {
754
782
  if (!messagePanelOpen) {
@@ -2346,22 +2374,41 @@ function isAnyModalOpen() {
2346
2374
 
2347
2375
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
2348
2376
 
2377
+ function zoomDelta(e) {
2378
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return undefined;
2379
+ return ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
2380
+ }
2381
+
2349
2382
  let _toastTimer = null;
2350
2383
  let _manualRefreshing = false;
2351
2384
  //#endregion
2352
2385
 
2353
2386
  //#region TOAST
2354
- function showToast(msg, type) {
2387
+ // `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
2388
+ function showToast(msg, type, action) {
2355
2389
  const el = document.getElementById('toast');
2356
2390
  clearTimeout(_toastTimer);
2357
2391
  el.style.transition = 'none';
2358
- el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info');
2392
+ el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
2359
2393
  void el.offsetHeight;
2360
2394
  el.style.transition = '';
2361
2395
  el.textContent = msg;
2396
+ const hide = () => el.classList.remove('visible', 'has-action');
2397
+ if (action) {
2398
+ const btn = document.createElement('button');
2399
+ btn.type = 'button';
2400
+ btn.className = 'toast-action';
2401
+ btn.textContent = action.label;
2402
+ btn.addEventListener('click', () => {
2403
+ hide();
2404
+ action.onClick();
2405
+ });
2406
+ el.append(btn);
2407
+ el.classList.add('has-action');
2408
+ }
2362
2409
  if (type) el.classList.add(`toast-${type}`);
2363
2410
  el.classList.add('visible');
2364
- _toastTimer = setTimeout(() => el.classList.remove('visible'), 2000);
2411
+ _toastTimer = setTimeout(hide, action ? 8000 : 2000);
2365
2412
  }
2366
2413
 
2367
2414
  async function copyWithFeedback(text, btn) {
@@ -3343,7 +3390,7 @@ function renderSessions() {
3343
3390
 
3344
3391
  if (zenMode) {
3345
3392
  emptyMsg = 'Zen mode: no session open';
3346
- emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
3393
+ emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3347
3394
  } else if (searchQuery) {
3348
3395
  emptyMsg = `No results for "${searchQuery}"`;
3349
3396
  emptyHint = 'Try a different search term or clear the search';
@@ -5499,7 +5546,7 @@ const SHORTCUT_PAIRS = [
5499
5546
  { keys: ['['], label: 'Toggle sidebar' },
5500
5547
  { keys: ['T'], label: 'Toggle theme' },
5501
5548
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5502
- { keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5549
+ { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5503
5550
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5504
5551
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5505
5552
  { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
@@ -5536,6 +5583,7 @@ const SHORTCUT_PAIRS = [
5536
5583
  { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5537
5584
  { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5538
5585
  { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5586
+ { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5539
5587
  ],
5540
5588
  },
5541
5589
  {
@@ -6081,11 +6129,12 @@ function _renderStorageLinkedDocs() {
6081
6129
  const name = linkedDocLabel(p);
6082
6130
  const sid = escAttrJs(sessionId);
6083
6131
  const jsPath = escAttrJs(p);
6132
+ const what = LINKED_DOC_OPENERS[linkedDocOpener(p)].noun;
6084
6133
  return `<div class="storage-item" style="padding-left:24px;">
6085
6134
  <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
6086
6135
  <div class="storage-item-actions">
6087
6136
  <button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
6088
- <button onclick="copyWithFeedback('${jsPath}', this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
6137
+ <button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${escapeHtml(what)}" aria-label="Copy ${escapeHtml(what)}">${ICON_COPY}</button>
6089
6138
  <button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
6090
6139
  </div>
6091
6140
  </div>`;
@@ -6234,13 +6283,11 @@ document.addEventListener('keydown', (e) => {
6234
6283
  // Scale the open modal's reading surface instead of letting the browser zoom
6235
6284
  // the whole page. Sits above the text-field guard so it still works with the
6236
6285
  // caret in a field inside the modal.
6237
- if ((e.ctrlKey || e.metaKey) && !e.altKey) {
6238
- const delta = ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
6239
- if (delta !== undefined && isZoomableModalOpen()) {
6240
- e.preventDefault();
6241
- adjustModalZoom(delta);
6242
- return;
6243
- }
6286
+ const zoom = zoomDelta(e);
6287
+ if (zoom !== undefined && isZoomableModalOpen()) {
6288
+ e.preventDefault();
6289
+ adjustModalZoom(zoom);
6290
+ return;
6244
6291
  }
6245
6292
 
6246
6293
  // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
@@ -6911,10 +6958,13 @@ async function handlePreviewOpenEvent(data) {
6911
6958
  // Linked docs live in localStorage, so the CLI can only reach them through a tab:
6912
6959
  // the server broadcasts the resolved path and every tab applies it idempotently.
6913
6960
  function handleDocumentLinkEvent(data) {
6914
- const { path: filePath, sessionId, unlink } = data;
6961
+ const { path: filePath, sessionId, unlink, open } = data;
6915
6962
  if (!filePath || !sessionId) return;
6916
6963
  setSessionDocLink(sessionId, filePath, unlink);
6917
- showToast(`${unlink ? 'Unlinked' : 'Linked'} ${filePath.split(/[\\/]/).pop()}`);
6964
+ const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
6965
+ // Every tab gets the event; only the one on screen offers to open the URL.
6966
+ if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
6967
+ else showToast(msg);
6918
6968
  }
6919
6969
 
6920
6970
  function getSessionBaseDir(sessionId) {
@@ -6996,10 +7046,13 @@ function isScratchpadPath(p) {
6996
7046
  // `linkedDocOpener` is the only place a linked path is classified; the row's tag,
6997
7047
  // tooltip and action are all read from here, so a new destination is one entry.
6998
7048
  const LINKED_DOC_OPENERS = {
6999
- preview: { tag: '', hint: '' },
7049
+ preview: { tag: '', hint: '', cls: '', noun: 'path' },
7050
+ url: { tag: '', hint: ' — opens in a new tab', cls: ' is-url', noun: 'URL' },
7000
7051
  scratch: {
7001
7052
  tag: '(scratchpad)',
7002
7053
  hint: ' — opens in the scratch viewer',
7054
+ cls: ' is-editor',
7055
+ noun: 'path',
7003
7056
  url: '/api/scratchpad/open',
7004
7057
  body: (p) => ({ path: p }),
7005
7058
  toast: 'in the scratch viewer',
@@ -7007,6 +7060,8 @@ const LINKED_DOC_OPENERS = {
7007
7060
  editor: {
7008
7061
  tag: '(editor)',
7009
7062
  hint: ' — opens in editor',
7063
+ cls: ' is-editor',
7064
+ noun: 'path',
7010
7065
  url: '/api/open-in-editor',
7011
7066
  body: (p) => ({ file: p }),
7012
7067
  toast: 'in editor',
@@ -7017,6 +7072,7 @@ const LINKED_DOC_OPENERS = {
7017
7072
  // text, but the pad is what the user linked, so the viewer wins over the raw file.
7018
7073
  // Without the CLI there is nothing to launch, so it falls back to the editor.
7019
7074
  function linkedDocOpener(p) {
7075
+ if (linkUrl(p)) return 'url';
7020
7076
  if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
7021
7077
  // A path whose kind has not arrived yet renders as previewable, which is what the
7022
7078
  // click does anyway — openPreviewByPath falls back to the editor on a null kind.
@@ -7026,12 +7082,17 @@ function linkedDocOpener(p) {
7026
7082
  // Every manifest is called scratchpad.json, so the pad's folder names it. Kept
7027
7083
  // independent of the opener: the pad is still the pad when the CLI is missing.
7028
7084
  function linkedDocLabel(p) {
7085
+ if (linkUrl(p)) return p.replace(/^https?:\/\//i, '').replace(/\/$/, '');
7029
7086
  const parts = p.split(/[\\/]/).filter(Boolean);
7030
7087
  return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
7031
7088
  }
7032
7089
 
7033
7090
  function openLinkedDoc(p, baseDir) {
7034
7091
  const opener = linkedDocOpener(p);
7092
+ if (opener === 'url') {
7093
+ openExternal(p);
7094
+ return;
7095
+ }
7035
7096
  if (opener === 'preview') {
7036
7097
  openPreviewByPath(p, baseDir, openFileInEditor);
7037
7098
  return;
@@ -7044,21 +7105,24 @@ function renderLinkedDocsHtml(sessionId) {
7044
7105
  const paths = getSessionPreviewPaths(sessionId);
7045
7106
  // Kicked off from the render rather than the three call sites, so a new surface that
7046
7107
  // shows linked docs cannot forget it. Returns at once once every path is cached.
7047
- loadPreviewKinds(sessionId, paths);
7108
+ const rows = paths.map((p) => ({ p, opener: linkedDocOpener(p) }));
7109
+ const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
7110
+ loadPreviewKinds(sessionId, filePaths);
7048
7111
  const baseDir = getSessionBaseDir(sessionId);
7049
- const items = paths
7050
- .map((p) => {
7051
- const opener = linkedDocOpener(p);
7052
- const { tag, hint } = LINKED_DOC_OPENERS[opener];
7112
+ const items = rows
7113
+ .map(({ p, opener }) => {
7114
+ const { tag, hint, cls, noun } = LINKED_DOC_OPENERS[opener];
7053
7115
  const name = linkedDocLabel(p);
7116
+ const isUrl = opener === 'url';
7054
7117
  const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
7055
7118
  const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
7056
7119
  const attr = escapeHtml(p);
7057
- return `<li class="linked-doc-item${opener === 'preview' ? '' : ' is-editor'}">
7058
- <a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>
7120
+ const href = isUrl ? `href="${attr}" target="_blank" rel="noopener"` : 'href="#"';
7121
+ return `<li class="linked-doc-item${escapeHtml(cls)}">
7122
+ <a ${href} class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>${isUrl ? '<span class="linked-doc-external" aria-hidden="true">↗</span>' : ''}
7059
7123
  ${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
7060
7124
  <span class="row-actions linked-doc-actions">
7061
- <button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy path" aria-label="Copy path of ${escapeHtml(name)}">${ICON_COPY}</button>
7125
+ <button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${escapeHtml(noun)}" aria-label="Copy ${escapeHtml(noun)} of ${escapeHtml(name)}">${ICON_COPY}</button>
7062
7126
  <button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">&times;</button>
7063
7127
  </span>
7064
7128
  </li>`;
@@ -7067,13 +7131,13 @@ function renderLinkedDocsHtml(sessionId) {
7067
7131
  // Rendered even when empty — the add button has to stay reachable.
7068
7132
  const body = paths.length
7069
7133
  ? `<ul class="linked-doc-list">${items}</ul>`
7070
- : '<div class="linked-docs-empty">No linked files yet</div>';
7134
+ : '<div class="linked-docs-empty">No linked documents yet</div>';
7071
7135
  return `<div class="linked-docs-section panel-section">
7072
7136
  <div class="panel-section-header">
7073
7137
  ${linkSvg(12)}
7074
7138
  <span>Linked documents</span>
7075
7139
  <span class="panel-section-count">${paths.length}</span>
7076
- <button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
7140
+ <button type="button" class="linked-docs-add-btn" title="Link a file or URL" aria-label="Link a file or URL">+</button>
7077
7141
  </div>
7078
7142
  <div class="linked-doc-editor-slot"></div>
7079
7143
  ${body}
@@ -7342,7 +7406,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7342
7406
  if (!container) return;
7343
7407
  container.addEventListener('click', (e) => {
7344
7408
  const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
7345
- if (!hit) return;
7409
+ if (!hit || hit.target === '_blank') return;
7346
7410
  e.preventDefault();
7347
7411
  if (hit.classList.contains('linked-docs-add-btn')) {
7348
7412
  startLinkedDocInput(container, sessionId);
@@ -7361,7 +7425,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7361
7425
  function startLinkedDocInput(container, sessionId) {
7362
7426
  const slot = container.querySelector('.linked-doc-editor-slot');
7363
7427
  if (!slot) return;
7364
- slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, file:// URL, or relative to the session cwd">
7428
+ slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Path, file:// or https:// URL">
7365
7429
  <div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
7366
7430
  <div class="linked-doc-error"></div>
7367
7431
  <div class="edit-actions">
@@ -7375,6 +7439,9 @@ function startLinkedDocInput(container, sessionId) {
7375
7439
  };
7376
7440
  slot.querySelector('.edit-save').addEventListener('click', save);
7377
7441
  slot.querySelector('.edit-cancel').addEventListener('click', cancel);
7442
+ input.addEventListener('input', () => {
7443
+ slot.querySelector('.linked-doc-error').textContent = '';
7444
+ });
7378
7445
  input.addEventListener('keydown', (e) => {
7379
7446
  if (e.key === 'Enter') {
7380
7447
  e.preventDefault();
@@ -7397,27 +7464,38 @@ async function linkFileByPath(sessionId, raw, slot) {
7397
7464
  else showToast(msg, 'error');
7398
7465
  };
7399
7466
  if (!value) {
7400
- fail('Enter a file path');
7467
+ fail('Enter a file path or URL');
7401
7468
  return;
7402
7469
  }
7403
- try {
7404
- const qs = new URLSearchParams({ path: value });
7405
- const base = getSessionBaseDir(sessionId);
7406
- if (base) qs.set('base', base);
7407
- const r = await fetch(`/api/file/resolve?${qs}`);
7408
- const data = await r.json().catch(() => ({}));
7409
- if (!r.ok || !data.exists) {
7410
- fail(data.error || 'File not found');
7470
+ let target;
7471
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^file:/i.test(value)) {
7472
+ target = linkUrl(value);
7473
+ if (!target) {
7474
+ fail('Only http and https URLs can be linked');
7475
+ return;
7476
+ }
7477
+ } else {
7478
+ try {
7479
+ const qs = new URLSearchParams({ path: value });
7480
+ const base = getSessionBaseDir(sessionId);
7481
+ if (base) qs.set('base', base);
7482
+ const r = await fetch(`/api/file/resolve?${qs}`);
7483
+ const data = await r.json().catch(() => ({}));
7484
+ if (!r.ok || !data.exists) {
7485
+ fail(data.error || 'File not found');
7486
+ return;
7487
+ }
7488
+ target = data.path;
7489
+ } catch {
7490
+ fail('Failed to resolve file');
7411
7491
  return;
7412
7492
  }
7413
- // Close the editor before the refresh: a sidebar panel defers its re-render while an
7414
- // input is open, so leaving it up would hide the file that was just linked.
7415
- if (slot) slot.innerHTML = '';
7416
- setSessionDocLink(sessionId, data.path, false);
7417
- showToast('Linked to session', 'success');
7418
- } catch {
7419
- fail('Failed to resolve file');
7420
7493
  }
7494
+ // Close the editor before the refresh: a sidebar panel defers its re-render while an
7495
+ // input is open, so leaving it up would hide the file that was just linked.
7496
+ if (slot) slot.innerHTML = '';
7497
+ setSessionDocLink(sessionId, target, false);
7498
+ showToast('Linked to session', 'success');
7421
7499
  }
7422
7500
  //#endregion
7423
7501
 
@@ -8246,10 +8324,44 @@ function getMermaidTheme() {
8246
8324
  return isLightTheme() ? 'default' : 'dark';
8247
8325
  }
8248
8326
 
8327
+ // Mermaid is about 3 MB, so it loads on the first diagram instead of on the page's critical path.
8328
+ const MERMAID_SCRIPT = {
8329
+ src: 'https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js',
8330
+ integrity: 'sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI',
8331
+ };
8332
+ let mermaidLoading = null;
8333
+
8334
+ function loadMermaid() {
8335
+ mermaidLoading ??= new Promise((resolve, reject) => {
8336
+ const script = document.createElement('script');
8337
+ script.src = MERMAID_SCRIPT.src;
8338
+ script.integrity = MERMAID_SCRIPT.integrity;
8339
+ script.crossOrigin = 'anonymous';
8340
+ script.onload = () => {
8341
+ mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
8342
+ resolve();
8343
+ };
8344
+ script.onerror = () => {
8345
+ script.remove();
8346
+ mermaidLoading = null;
8347
+ reject(new Error('mermaid failed to load'));
8348
+ };
8349
+ document.head.appendChild(script);
8350
+ });
8351
+ return mermaidLoading;
8352
+ }
8353
+
8249
8354
  function initMermaidBlocks(container) {
8250
- if (typeof mermaid === 'undefined') return;
8251
8355
  const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
8252
- if (blocks.length) mermaid.run({ nodes: [...blocks] });
8356
+ if (!blocks.length) return;
8357
+ if (typeof mermaid === 'undefined') {
8358
+ loadMermaid().then(
8359
+ () => initMermaidBlocks(container),
8360
+ (err) => console.warn(err.message),
8361
+ );
8362
+ return;
8363
+ }
8364
+ mermaid.run({ nodes: [...blocks] });
8253
8365
  }
8254
8366
 
8255
8367
  function reinitMermaidTheme() {
@@ -8563,7 +8675,9 @@ function renderZenState() {
8563
8675
  if (!btn) return;
8564
8676
  btn.classList.toggle('active', zenMode);
8565
8677
  btn.setAttribute('aria-pressed', String(zenMode));
8566
- btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
8678
+ btn.title = zenMode
8679
+ ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
8680
+ : 'Zen mode: current session only (Ctrl+Shift+Z)';
8567
8681
  }
8568
8682
 
8569
8683
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -10153,6 +10267,9 @@ const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
10153
10267
  // Swap to the previous session.
10154
10268
  const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
10155
10269
  const TERMINAL_MODES_KEY = 'terminal-sessions';
10270
+ const TERMINAL_FONT_KEY = 'terminal-font-size';
10271
+ const TERMINAL_FONT_MIN = 8;
10272
+ const TERMINAL_FONT_MAX = 32;
10156
10273
  const ACK_BATCH_BYTES = 32 * 1024;
10157
10274
  const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
10158
10275
  const TERMINAL_STABLE_MS = 5000;
@@ -10265,11 +10382,24 @@ function terminalPaneFocused() {
10265
10382
  return document.getElementById('terminal-pane').contains(document.activeElement);
10266
10383
  }
10267
10384
 
10385
+ const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
10386
+
10268
10387
  function terminalShortcut(e) {
10269
10388
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10389
+ const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
10390
+ // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
10391
+ const zoom = e.key === '_' ? undefined : zoomDelta(e);
10392
+ if (zoom !== undefined && termState.term && terminalPaneFocused()) {
10393
+ return () => adjustTerminalFontSize(Math.sign(zoom));
10394
+ }
10270
10395
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10271
10396
  // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
10272
- if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && e.code === 'KeyP') return toggleSessionPicker;
10397
+ if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
10398
+ // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
10399
+ if (ctrlShift && e.code === 'KeyZ') {
10400
+ const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
10401
+ return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
10402
+ }
10273
10403
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10274
10404
  return () => openNewSession(null, e.code === 'KeyR');
10275
10405
  }
@@ -10379,7 +10509,7 @@ function takeTerminalFocus(sessionId) {
10379
10509
  // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
10380
10510
  function promptAwaitsUser(sessionId) {
10381
10511
  if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
10382
- return !document.activeElement?.matches('input, textarea, select, [contenteditable]');
10512
+ return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
10383
10513
  }
10384
10514
 
10385
10515
  function onTerminalShown(focus = true) {
@@ -10480,9 +10610,10 @@ function ensureTerm() {
10480
10610
  const host = document.getElementById('terminal-host');
10481
10611
  const fontFamily =
10482
10612
  cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
10613
+ const fontSize = terminalFontSize();
10483
10614
  const term = new window.Terminal({
10484
10615
  fontFamily,
10485
- fontSize: cfg.fontSize,
10616
+ fontSize,
10486
10617
  scrollback: cfg.scrollback,
10487
10618
  cursorBlink: true,
10488
10619
  allowProposedApi: true,
@@ -10506,7 +10637,7 @@ function ensureTerm() {
10506
10637
  // No WebGL: xterm falls back to its DOM renderer.
10507
10638
  }
10508
10639
  document.fonts
10509
- ?.load(`${cfg.fontSize}px ${fontFamily}`)
10640
+ ?.load(`${fontSize}px ${fontFamily}`)
10510
10641
  .then(repaintTerminal)
10511
10642
  .catch(() => {});
10512
10643
  term.attachCustomKeyEventHandler(terminalKeyFilter);
@@ -10586,6 +10717,27 @@ function fitTerminal() {
10586
10717
  } catch (_) {}
10587
10718
  }
10588
10719
 
10720
+ function clampTerminalFont(v) {
10721
+ return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
10722
+ }
10723
+
10724
+ function terminalFontSize() {
10725
+ const saved = Number.parseInt(store.getItem(TERMINAL_FONT_KEY), 10);
10726
+ return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
10727
+ }
10728
+
10729
+ function adjustTerminalFontSize(step) {
10730
+ const term = termState.term;
10731
+ const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
10732
+ if (size !== term.options.fontSize) {
10733
+ if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
10734
+ else store.setItem(TERMINAL_FONT_KEY, String(size));
10735
+ term.options.fontSize = size;
10736
+ fitTerminal();
10737
+ }
10738
+ showToast(`Terminal ${size}px`);
10739
+ }
10740
+
10589
10741
  function terminalSend(msg) {
10590
10742
  if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
10591
10743
  }
@@ -11464,19 +11616,16 @@ document.addEventListener('DOMContentLoaded', () => {
11464
11616
  marked.use({ renderer });
11465
11617
  }
11466
11618
 
11467
- if (typeof mermaid !== 'undefined') {
11468
- mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
11469
- let mermaidPending = false;
11470
- const mo = new MutationObserver(() => {
11471
- if (mermaidPending) return;
11472
- mermaidPending = true;
11473
- queueMicrotask(() => {
11474
- mermaidPending = false;
11475
- initMermaidBlocks();
11476
- });
11619
+ let mermaidPending = false;
11620
+ const mo = new MutationObserver(() => {
11621
+ if (mermaidPending) return;
11622
+ mermaidPending = true;
11623
+ queueMicrotask(() => {
11624
+ mermaidPending = false;
11625
+ initMermaidBlocks();
11477
11626
  });
11478
- mo.observe(document.body, { childList: true, subtree: true });
11479
- }
11627
+ });
11628
+ mo.observe(document.body, { childList: true, subtree: true });
11480
11629
  });
11481
11630
 
11482
11631
  loadSidebarState();
@@ -11500,6 +11649,7 @@ initModalResize();
11500
11649
  loadPanelWidths();
11501
11650
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
11502
11651
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
11652
+ initMessagePanelFull();
11503
11653
 
11504
11654
  const msgContentEl = document.getElementById('message-panel-content');
11505
11655
  const jumpLatestBtn = document.createElement('button');
@@ -11536,7 +11686,7 @@ msgContentEl.addEventListener('wheel', function (e) {
11536
11686
  }
11537
11687
  });
11538
11688
 
11539
- const footerState = { version: null, limitsKey: null, timer: null };
11689
+ const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
11540
11690
  function formatResetIn(epochSec) {
11541
11691
  if (!epochSec) return null;
11542
11692
  const ms = epochSec * 1000 - Date.now();
@@ -11582,12 +11732,45 @@ function renderSidebarFooter(rateLimits) {
11582
11732
  const children = [];
11583
11733
  if (footerState.version) {
11584
11734
  const v = document.createElement('span');
11735
+ v.className = 'footer-version';
11585
11736
  v.textContent = `v${footerState.version}`;
11737
+ const warn = pluginWarning(footerState.plugin);
11738
+ if (warn) v.append(warn);
11586
11739
  children.push(v);
11587
11740
  }
11588
11741
  if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
11589
11742
  el.replaceChildren(...children);
11590
11743
  }
11744
+ function pluginWarning(plugin) {
11745
+ const problem =
11746
+ plugin &&
11747
+ {
11748
+ missing: 'The claude-code-kanban plugin is not installed',
11749
+ disabled: `The claude-code-kanban plugin ${plugin.installed} is disabled`,
11750
+ mismatch: `Plugin ${plugin.installed} is installed, this board ships ${plugin.bundled}`,
11751
+ }[plugin.state];
11752
+ if (!problem) return null;
11753
+ const fix =
11754
+ plugin.state === 'disabled'
11755
+ ? 'Enable it with /plugin in Claude Code.'
11756
+ : `Click to copy: ${plugin.installCommand}\nThen restart open sessions, they keep the old hooks.`;
11757
+ const btn = document.createElement('button');
11758
+ btn.type = 'button';
11759
+ btn.className = 'footer-plugin-warning';
11760
+ btn.textContent = '⚠';
11761
+ btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
11762
+ btn.setAttribute('aria-label', problem);
11763
+ if (plugin.state !== 'disabled') {
11764
+ btn.addEventListener('click', () =>
11765
+ navigator.clipboard
11766
+ .writeText(plugin.installCommand)
11767
+ .then(() => showToast('Install command copied', 'success'))
11768
+ .catch(() => showToast('Could not copy the command', 'error')),
11769
+ );
11770
+ }
11771
+ return btn;
11772
+ }
11773
+
11591
11774
  function refreshRateLimits() {
11592
11775
  if (footerState.timer) return;
11593
11776
  footerState.timer = setTimeout(() => {
@@ -11614,6 +11797,7 @@ fetch('/api/version')
11614
11797
  .then((r) => r.json())
11615
11798
  .then((d) => {
11616
11799
  footerState.version = d.version;
11800
+ footerState.plugin = d.plugin ?? null;
11617
11801
  renderSidebarFooter(null);
11618
11802
  refreshRateLimits();
11619
11803
  })
@@ -11782,6 +11966,13 @@ document.addEventListener('click', (e) => {
11782
11966
  hubPost({ type: 'hub:openExternal', url: url.href });
11783
11967
  });
11784
11968
 
11969
+ // The click handler above only catches anchors. A script open goes the same way, because
11970
+ // in the hub's installed PWA window a framed app's own `_blank` open does nothing.
11971
+ function openExternal(url) {
11972
+ if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
11973
+ else window.open(url, '_blank', 'noopener');
11974
+ }
11975
+
11785
11976
  window.hubNavigate = function hubNavigate(app, url) {
11786
11977
  if (!window.__HUB__?.enabled) return;
11787
11978
  hubPost({ type: 'hub:navigate', app, url });
package/public/index.html CHANGED
@@ -26,9 +26,9 @@
26
26
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" id="hljs-theme-dark">
27
27
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css" integrity="sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L" crossorigin="anonymous" id="hljs-theme-light" disabled>
28
28
  <script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js" integrity="sha384-wjfDDhOPPdjtva8vWBhWeVprSpmxisEu5aYT3q1JyACqXpdKpo3PWZTMVq24MBix" crossorigin="anonymous"></script>
29
- <script defer src="https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js" integrity="sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI" crossorigin="anonymous"></script>
30
29
  <script>window.__STORAGE_NS__ = null;</script>
31
30
  <script defer src="/project-match.js"></script>
31
+ <script defer src="/link-url.js"></script>
32
32
  <script defer src="/app.js"></script>
33
33
  </head>
34
34
  <body class="booting">
@@ -82,7 +82,7 @@
82
82
  id="zen-mode-btn"
83
83
  class="icon-btn section-btn"
84
84
  onclick="event.stopPropagation(); toggleZenMode()"
85
- title="Zen mode: current session only (Shift+Z)"
85
+ title="Zen mode: current session only (Ctrl+Shift+Z)"
86
86
  aria-label="Zen mode: current session only"
87
87
  aria-pressed="false"
88
88
  >
@@ -367,6 +367,9 @@
367
367
  <button class="icon-btn" onclick="openLiveLatestMessage()" title="Follow latest message (Shift+M)" aria-label="Follow latest message">
368
368
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
369
369
  </button>
370
+ <button id="message-panel-full-btn" class="icon-btn" onclick="toggleMessagePanelFull()" title="Expand to full width" aria-label="Expand to full width" aria-pressed="false">
371
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
372
+ </button>
370
373
  <button class="detail-close" onclick="toggleMessagePanel()" aria-label="Close message panel">
371
374
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
372
375
  <path d="M6 18L18 6M6 6l12 12"/>
@@ -0,0 +1,15 @@
1
+ // Loaded by the page as a plain script and by lib/linked-docs.js through require, so the
2
+ // sidebar, the API and the CLI accept the same URLs. Only http(s): linked docs render as
3
+ // links, so a `javascript:` or `data:` value sent through the API must not become one.
4
+ function linkUrl(value) {
5
+ if (typeof value !== 'string') return null;
6
+ const v = value.trim();
7
+ if (!/^https?:\/\//i.test(v)) return null;
8
+ try {
9
+ return new URL(v).href;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ if (typeof module === 'object' && module.exports) module.exports = { linkUrl };
package/public/style.css CHANGED
@@ -16,7 +16,7 @@
16
16
  --text-muted: #7d808a;
17
17
  --accent: #e86f33;
18
18
  --accent-text: #f0a070;
19
- --accent-dim: rgba(232, 111, 51, 0.22);
19
+ --accent-dim: rgba(232, 111, 51, 0.25);
20
20
  --accent-glow: rgba(232, 111, 51, 0.55);
21
21
  --gold: #d9b667;
22
22
  --success: #3ecf8e;
@@ -1304,6 +1304,28 @@ body.booting .boot-splash {
1304
1304
  color: var(--text-secondary);
1305
1305
  }
1306
1306
 
1307
+ .sidebar-footer .footer-version {
1308
+ display: inline-flex;
1309
+ align-items: center;
1310
+ gap: 6px;
1311
+ }
1312
+
1313
+ .sidebar-footer .footer-plugin-warning {
1314
+ padding: 0;
1315
+ border: none;
1316
+ background: none;
1317
+ font: inherit;
1318
+ font-size: 11px;
1319
+ line-height: 1;
1320
+ color: var(--warning);
1321
+ cursor: pointer;
1322
+ }
1323
+
1324
+ .sidebar-footer .footer-plugin-warning:hover,
1325
+ .sidebar-footer .footer-plugin-warning:focus-visible {
1326
+ filter: brightness(1.2);
1327
+ }
1328
+
1307
1329
  .sidebar.collapsed {
1308
1330
  width: 48px;
1309
1331
  }
@@ -3208,6 +3230,16 @@ body.booting .boot-splash {
3208
3230
  .message-panel.msg-expanded-wide {
3209
3231
  width: 60vw;
3210
3232
  }
3233
+ .message-panel.full-width {
3234
+ left: var(--sidebar-edge, 0);
3235
+ width: auto;
3236
+ }
3237
+ .message-panel.full-width .panel-resize-handle {
3238
+ display: none;
3239
+ }
3240
+ #message-panel-full-btn.active {
3241
+ color: var(--accent);
3242
+ }
3211
3243
  .message-panel-header {
3212
3244
  padding: 16px 20px;
3213
3245
  display: flex;
@@ -3627,6 +3659,28 @@ body.booting .boot-splash {
3627
3659
  opacity: 1;
3628
3660
  transform: translateY(0);
3629
3661
  }
3662
+ .toast.has-action.visible {
3663
+ pointer-events: auto;
3664
+ display: flex;
3665
+ align-items: center;
3666
+ gap: 10px;
3667
+ max-width: min(480px, calc(100vw - 32px));
3668
+ overflow-wrap: anywhere;
3669
+ }
3670
+ .toast-action {
3671
+ flex-shrink: 0;
3672
+ font: inherit;
3673
+ font-weight: 600;
3674
+ color: var(--accent-text);
3675
+ background: transparent;
3676
+ border: 1px solid var(--accent);
3677
+ border-radius: 4px;
3678
+ padding: 2px 10px;
3679
+ cursor: pointer;
3680
+ }
3681
+ .toast-action:hover {
3682
+ background: var(--accent-dim);
3683
+ }
3630
3684
  .toast.toast-success {
3631
3685
  border-color: var(--success);
3632
3686
  color: var(--success);
@@ -4705,6 +4759,21 @@ body.booting .boot-splash {
4705
4759
  text-decoration-style: dashed;
4706
4760
  }
4707
4761
 
4762
+ .linked-doc-item.is-url .linked-doc-link {
4763
+ flex-shrink: 1;
4764
+ min-width: 0;
4765
+ overflow: hidden;
4766
+ text-overflow: ellipsis;
4767
+ white-space: nowrap;
4768
+ }
4769
+
4770
+ .linked-doc-external {
4771
+ flex-shrink: 0;
4772
+ color: var(--text-muted);
4773
+ font-size: 10px;
4774
+ margin-left: -4px;
4775
+ }
4776
+
4708
4777
  /* A section of the session panel: linked documents, artifacts. */
4709
4778
  .panel-section {
4710
4779
  margin-bottom: 16px;
@@ -5085,11 +5154,11 @@ body.light {
5085
5154
  --session-active-bg: var(--bg-elevated);
5086
5155
  --border: #cfcbc4;
5087
5156
  --text-primary: #0a0a0a;
5088
- --text-secondary: #444444;
5089
- --text-tertiary: #666666;
5090
- --text-muted: #888888;
5157
+ --text-secondary: #2c2c2c;
5158
+ --text-tertiary: #565656;
5159
+ --text-muted: #767676;
5091
5160
  --accent-text: #a0501c;
5092
- --accent-dim: rgba(232, 111, 51, 0.18);
5161
+ --accent-dim: rgba(184, 90, 32, 0.18);
5093
5162
  --accent-glow: rgba(232, 111, 51, 0.5);
5094
5163
  --gold: #a8842f;
5095
5164
  --success: #1a8a5a;
@@ -6677,10 +6746,11 @@ select.form-input option:checked {
6677
6746
  --session-active-bg: var(--bg-elevated);
6678
6747
  --border: #cfcbc4;
6679
6748
  --text-primary: #0a0a0a;
6680
- --text-secondary: #444444;
6681
- --text-tertiary: #666666;
6682
- --text-muted: #888888;
6683
- --accent-dim: rgba(232, 111, 51, 0.18);
6749
+ --text-secondary: #2c2c2c;
6750
+ --text-tertiary: #565656;
6751
+ --text-muted: #767676;
6752
+ --accent-text: #a0501c;
6753
+ --accent-dim: rgba(184, 90, 32, 0.18);
6684
6754
  --accent-glow: rgba(232, 111, 51, 0.5);
6685
6755
  --gold: #a8842f;
6686
6756
  --success: #1a8a5a;
package/public/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const CACHE_NAME = 'cc-kanban-v2';
2
- const PRECACHE_URLS = ['/', '/style.css', '/project-match.js', '/app.js'];
2
+ const PRECACHE_URLS = ['/', '/style.css', '/project-match.js', '/link-url.js', '/app.js'];
3
3
 
4
4
  let cachePromise = null;
5
5
  function getCache() {
package/public/themes.css CHANGED
@@ -38,6 +38,55 @@ body.light .theme-swatch-vitesse { --sw-bg: #f7f7f7; --sw-accent: #1c6b48; --sw-
38
38
  .theme-swatch-synthwave { --sw-bg: #262335; --sw-accent: #ff7edb; --sw-ink: #ffffff; --sw-border: #495495; }
39
39
  body.light .theme-swatch-synthwave { --sw-bg: #efecf6; --sw-accent: #c936a6; --sw-ink: #241b2f; --sw-border: #c5bcdd; }
40
40
 
41
+ /* Ember */
42
+ body:not([data-color-theme]):not(.light) {
43
+ --accent: #e86f33;
44
+ --accent-text: #f0a070;
45
+ --accent-dim: rgba(232,111,51,0.25);
46
+ --accent-glow: rgba(232, 111, 51, 0.55);
47
+ --bg-deep: #101114;
48
+ --bg-surface: #16181c;
49
+ --bg-elevated: #1e2025;
50
+ --bg-hover: #282a30;
51
+ --border: #363840;
52
+ --text-primary: #f0f1f3;
53
+ --text-secondary: #c2c4c9;
54
+ --text-tertiary: #9a9da5;
55
+ --text-muted: #7d808a;
56
+ }
57
+ body.light:not([data-color-theme]) {
58
+ --accent: #e86f33;
59
+ --accent-text: #a0501c;
60
+ --accent-dim: rgba(184,90,32,0.18);
61
+ --accent-glow: rgba(232, 111, 51, 0.5);
62
+ --bg-deep: #e8e6e3;
63
+ --bg-surface: #efede9;
64
+ --bg-elevated: #fbfaf9;
65
+ --bg-hover: #d2d0cc;
66
+ --border: #cfcbc4;
67
+ --text-primary: #0a0a0a;
68
+ --text-secondary: #2c2c2c;
69
+ --text-tertiary: #565656;
70
+ --text-muted: #767676;
71
+ }
72
+ @media (prefers-color-scheme: light) {
73
+ body:not(.dark-forced):not([data-color-theme]) {
74
+ --accent: #e86f33;
75
+ --accent-text: #a0501c;
76
+ --accent-dim: rgba(184,90,32,0.18);
77
+ --accent-glow: rgba(232, 111, 51, 0.5);
78
+ --bg-deep: #e8e6e3;
79
+ --bg-surface: #efede9;
80
+ --bg-elevated: #fbfaf9;
81
+ --bg-hover: #d2d0cc;
82
+ --border: #cfcbc4;
83
+ --text-primary: #0a0a0a;
84
+ --text-secondary: #2c2c2c;
85
+ --text-tertiary: #565656;
86
+ --text-muted: #767676;
87
+ }
88
+ }
89
+
41
90
  /* Gruvbox */
42
91
  body[data-color-theme="gruvbox"]:not(.light) {
43
92
  --accent: #fe8019;
package/server.js CHANGED
@@ -15,6 +15,7 @@ const { createNetGuard } = require('./lib/net-guard');
15
15
  const { isContained } = require('./lib/contain');
16
16
  const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
17
17
  const { fileUrlToPath } = require('./lib/file-url');
18
+ const { pluginStatus } = require('./lib/plugin-status');
18
19
 
19
20
  const {
20
21
  readRecentMessages: _readRecentMessagesUncached,
@@ -41,7 +42,7 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
41
42
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
42
43
  const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
43
44
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
44
- const { createLinkedDocStore } = require('./lib/linked-docs');
45
+ const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
45
46
  const { pickFolder } = require('./lib/folder-dialog');
46
47
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
47
48
  const { countTaskDir } = require('./lib/task-counts');
@@ -93,8 +94,12 @@ const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
93
94
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
94
95
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
95
96
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
97
+ // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
98
+ const TEMP_ROOT = (() => {
99
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
100
+ })();
96
101
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
97
- const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
102
+ const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
98
103
 
99
104
  // #endregion
100
105
 
@@ -1604,10 +1609,6 @@ app.get('/api/sessions/search', (req, res) => {
1604
1609
  res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
1605
1610
  });
1606
1611
 
1607
- // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
1608
- const TEMP_ROOT = (() => {
1609
- try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
1610
- })();
1611
1612
  function isTempPath(p) {
1612
1613
  const rel = path.relative(TEMP_ROOT, p);
1613
1614
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1842,8 +1843,9 @@ async function readCreatedPads(meta) {
1842
1843
  // The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
1843
1844
  // into subdirectories — a session that made a pad and then worked inside it reports
1844
1845
  // a cwd below the pad, and a scan from there finds nothing above it.
1845
- const root = meta.project || meta.cwd;
1846
- const scanned = unresolved.length && root ? await findPads(root) : [];
1846
+ // The session's own scratchpad dir too: a pad made there sits outside the project.
1847
+ const roots = unresolved.length ? [meta.project || meta.cwd, getScratchpadDir(sessionId, meta)] : [];
1848
+ const scanned = (await Promise.all(roots.filter(Boolean).map((r) => findPads(r)))).flat();
1847
1849
  const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
1848
1850
  const rows = await Promise.all(
1849
1851
  candidates.map(async (file) => {
@@ -3160,7 +3162,7 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
3160
3162
  // #region META_ROUTES
3161
3163
  app.get('/api/version', (_req, res) => {
3162
3164
  const pkg = require('./package.json');
3163
- res.json({ version: pkg.version });
3165
+ res.json({ version: pkg.version, plugin: pluginStatus(CLAUDE_DIR) });
3164
3166
  });
3165
3167
 
3166
3168
  app.get('/api/config', (_req, res) => {
@@ -3696,16 +3698,19 @@ app.post('/api/preview', async (req, res) => {
3696
3698
  // API: Link a file to a session's sidebar docs without opening the preview modal.
3697
3699
  // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
3698
3700
  // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
3701
+ // An http(s) URL is linked as it is. `open` comes from `preview-doc <url>`: a tab cannot
3702
+ // open a URL without a click, so the tab on screen offers an Open button.
3699
3703
  app.post('/api/document/link', async (req, res) => {
3700
3704
  try {
3701
- const { path: filePath, sessionId, unlink } = req.body || {};
3705
+ const { path: filePath, sessionId, unlink, open } = req.body || {};
3702
3706
  if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
3703
- const abs = resolvePreviewPath(filePath);
3707
+ const url = linkUrl(filePath);
3708
+ const abs = url || resolvePreviewPath(filePath);
3704
3709
  if (!abs) return res.status(400).json({ error: 'path is required' });
3705
- if (!unlink) await statFileTarget(abs);
3710
+ if (!unlink && !url) await statFileTarget(abs);
3706
3711
  if (unlink) linkedDocs.unlink(sessionId, abs);
3707
3712
  else linkedDocs.link(sessionId, abs);
3708
- broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
3713
+ broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink, open: !!(url && open && !unlink) });
3709
3714
  res.json({ success: true, path: abs, tabs: clients.size });
3710
3715
  } catch (error) {
3711
3716
  console.error('Error in /api/document/link:', error);
@@ -3722,7 +3727,8 @@ app.get('/api/document/links', (req, res) => {
3722
3727
  // The browser's own unlink: it already updated its list, so this only drops the server
3723
3728
  // copy that would bring the doc back on the next merge. No path clears the session.
3724
3729
  app.delete('/api/document/links/:sessionId', (req, res) => {
3725
- const filePath = req.query.path ? resolvePreviewPath(String(req.query.path)) : null;
3730
+ const raw = String(req.query.path || '');
3731
+ const filePath = linkUrl(raw) || resolvePreviewPath(raw);
3726
3732
  res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
3727
3733
  });
3728
3734