claude-code-kanban 7.12.1 → 7.14.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/cli.js +35 -1
- package/hub-app.json +1 -1
- package/lib/kanbot.js +49 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +20 -14
- package/plugin/plugins/claude-code-kanban/skills/visualize/blocks.html +68 -0
- package/plugin/plugins/claude-code-kanban/skills/visualize/template.html +23 -59
- package/public/app.js +323 -65
- package/public/index.html +1 -1
- package/public/style.css +109 -1
- package/server.js +17 -2
package/cli.js
CHANGED
|
@@ -141,6 +141,17 @@ const COMMANDS = {
|
|
|
141
141
|
examples: ['claude-code-kanban session open $CLAUDE_SESSION_ID'],
|
|
142
142
|
run: runSessionOpenCli,
|
|
143
143
|
},
|
|
144
|
+
cost: {
|
|
145
|
+
summary: 'Show a session (or one subagent) in the Cost app',
|
|
146
|
+
usage: 'claude-code-kanban session cost <id> [--agent <agent id>]',
|
|
147
|
+
flags: {
|
|
148
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
149
|
+
'--agent <agent id>': 'A subagent of the session (agent-…), as claude-code-cost session prints it',
|
|
150
|
+
},
|
|
151
|
+
notes: 'Under the hub the board switches to the Cost tab. The command also prints the cost link, for a board that runs alone.',
|
|
152
|
+
examples: ['claude-code-kanban session cost 3fa9c1 --agent agent-a10ff42'],
|
|
153
|
+
run: runSessionCostCli,
|
|
154
|
+
},
|
|
144
155
|
view: {
|
|
145
156
|
summary: 'Show full session stats (metadata + context window + cost) and the transcript path',
|
|
146
157
|
usage: 'claude-code-kanban session view <id> [--json]',
|
|
@@ -943,6 +954,29 @@ async function runSessionOpenCli(args) {
|
|
|
943
954
|
} catch (e) { reportCliError(e); return 1; }
|
|
944
955
|
}
|
|
945
956
|
|
|
957
|
+
const AGENT_ID_RE = /^agent-[\w-]+$/;
|
|
958
|
+
|
|
959
|
+
async function runSessionCostCli(args) {
|
|
960
|
+
const entry = COMMANDS.session.verbs.cost;
|
|
961
|
+
const [idArg] = positionals(args, ['--agent']);
|
|
962
|
+
if (!idArg) {
|
|
963
|
+
printLeafHelp(entry);
|
|
964
|
+
return 1;
|
|
965
|
+
}
|
|
966
|
+
const agent = getArgValue(args, 'agent');
|
|
967
|
+
const agentGiven = args.some((a) => a === '--agent' || a.startsWith('--agent='));
|
|
968
|
+
if (agentGiven && !AGENT_ID_RE.test(agent || '')) return usageError(entry, '--agent takes an agent-… id.');
|
|
969
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
970
|
+
if (!resolved) return 1;
|
|
971
|
+
try {
|
|
972
|
+
const out = await cliPostJson('/api/session/cost', { id: resolved.id, ...(agent && { agent }) }, 'Cost');
|
|
973
|
+
if (!out) return 1;
|
|
974
|
+
console.log(out.delivered ? 'Sent to the board: it opens the Cost tab.' : 'No hub board page is open to show it.');
|
|
975
|
+
if (out.url) console.log(`Cost view: ${out.url}`);
|
|
976
|
+
return out.delivered || out.url ? 0 : 1;
|
|
977
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
978
|
+
}
|
|
979
|
+
|
|
946
980
|
async function runSessionPinCli(args) {
|
|
947
981
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
948
982
|
if (!idArg) {
|
|
@@ -1521,4 +1555,4 @@ async function runSkillsGetCli(args) {
|
|
|
1521
1555
|
return 0;
|
|
1522
1556
|
}
|
|
1523
1557
|
|
|
1524
|
-
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };
|
|
1558
|
+
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE };
|
package/hub-app.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
-
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
9
|
"plugin": {
|
|
10
10
|
"marketplace": "plugin",
|
|
11
11
|
"name": "claude-code-kanban",
|
package/lib/kanbot.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { encodeProjectDirName } = require('./claude-dir');
|
|
6
6
|
const { MODELS } = require('./terminal');
|
|
7
|
+
const { isPidAlive } = require('./live-sessions');
|
|
7
8
|
const { topHelp } = require('../cli');
|
|
8
9
|
|
|
9
10
|
const PLUGIN_DIR = path.join(__dirname, '..', 'kanbot');
|
|
@@ -19,15 +20,56 @@ function kanbotModel(cfg) {
|
|
|
19
20
|
return MODELS.has(model) ? model : null;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function
|
|
23
|
+
function costSection(costCli) {
|
|
24
|
+
if (!costCli) return '';
|
|
25
|
+
return [
|
|
26
|
+
'\n# Costs\n',
|
|
27
|
+
`\`${costCli} help\` lists the commands for Claude Code costs: by day, model, project, session and subagent.`,
|
|
28
|
+
'To show a session, or one of its subagents, to the user: `claude-code-kanban session cost <id> [--agent <agent id>]`.\n',
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeHelpPrompt(cwd, boardUrl, costCli) {
|
|
23
33
|
const file = path.join(cwd, 'cli-help.md');
|
|
24
34
|
const board = boardUrl ? `This board runs at ${boardUrl}; \`$CCK_URL\` holds the same URL, so the CLI talks to it.\n\n` : '';
|
|
25
|
-
fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n`);
|
|
35
|
+
fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n${costSection(costCli)}`);
|
|
26
36
|
return file;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
function kanbotArgs(cwd, claudeDir, boardUrl) {
|
|
30
|
-
return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl), '--add-dir', claudeDir];
|
|
39
|
+
function kanbotArgs(cwd, claudeDir, boardUrl, costCli) {
|
|
40
|
+
return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl, costCli), '--add-dir', claudeDir];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A claude-code-cost before its CLI starts a dashboard on any command, so a bin on PATH counts only
|
|
44
|
+
// when its package has lib/cli.js. npm puts the package next to a Windows shim, and a POSIX bin is
|
|
45
|
+
// a symlink to server.js.
|
|
46
|
+
function costPackageHasCli(bin) {
|
|
47
|
+
const dirs = [path.join(path.dirname(bin), 'node_modules', 'claude-code-cost')];
|
|
48
|
+
try {
|
|
49
|
+
dirs.push(path.dirname(fs.realpathSync(bin)));
|
|
50
|
+
} catch {}
|
|
51
|
+
return dirs.some((d) => fs.existsSync(path.join(d, 'lib', 'cli.js')));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The hub gives cck no cost URL. The cost server of this config dir records itself in server.json
|
|
55
|
+
// ({pid, port, script}); a killed one leaves the file behind.
|
|
56
|
+
function readCostServer(claudeDir) {
|
|
57
|
+
let rec;
|
|
58
|
+
try {
|
|
59
|
+
rec = JSON.parse(fs.readFileSync(path.join(claudeDir, '.cost', 'server.json'), 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(rec?.pid) || !Number.isInteger(rec?.port) || !isPidAlive(rec.pid)) return null;
|
|
64
|
+
return { url: `http://localhost:${rec.port}`, script: typeof rec.script === 'string' ? rec.script : null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Under the hub the CLI is not on PATH, so the running cost server names its own script.
|
|
68
|
+
function findCostCli({ claudeDir, which }) {
|
|
69
|
+
const script = readCostServer(claudeDir)?.script;
|
|
70
|
+
if (script) return `node "${script.replace(/\\/g, '/')}"`;
|
|
71
|
+
const bin = which('claude-code-cost');
|
|
72
|
+
return bin && costPackageHasCli(bin) ? 'claude-code-cost' : null;
|
|
31
73
|
}
|
|
32
74
|
|
|
33
75
|
function createKanbot({ cckDir, projectsDir }) {
|
|
@@ -70,10 +112,10 @@ function createKanbot({ cckDir, projectsDir }) {
|
|
|
70
112
|
sessionId = id;
|
|
71
113
|
ownIds.add(id);
|
|
72
114
|
},
|
|
73
|
-
startSpec({ model = null, boardUrl = null } = {}) {
|
|
115
|
+
startSpec({ model = null, boardUrl = null, costCli = null } = {}) {
|
|
74
116
|
fs.mkdirSync(cwd, { recursive: true });
|
|
75
117
|
sessionId = newestTranscript();
|
|
76
|
-
const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl);
|
|
118
|
+
const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl, costCli);
|
|
77
119
|
// Not restored on a cck restart: a restore would bring it back without the agent args.
|
|
78
120
|
return { cwd, name: 'kanbot', model, extraArgs, restore: false, ...(sessionId && { resume: sessionId }) };
|
|
79
121
|
},
|
|
@@ -83,4 +125,4 @@ function createKanbot({ cckDir, projectsDir }) {
|
|
|
83
125
|
};
|
|
84
126
|
}
|
|
85
127
|
|
|
86
|
-
module.exports = { kanbotOn, kanbotModel, createKanbot, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
|
|
128
|
+
module.exports = { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.11",
|
|
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
|
}
|
|
@@ -16,7 +16,7 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
|
|
18
18
|
| `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
|
|
19
|
-
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
|
|
19
|
+
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `cost <id> [--agent <agent id>]` opens its costs · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
|
|
20
20
|
| `group` | `list` the sidebar groups, by name and id · `create <name>` · `rename <group> <name>` · `rm <group>` · `prune` the empty ones |
|
|
21
21
|
| `task` | `list` for a session, a project or the whole board |
|
|
22
22
|
| `project` | `list` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: visualize
|
|
3
|
-
description: Present a topic as one self-contained HTML page —
|
|
3
|
+
description: Present a topic as one self-contained HTML page whose form fits the content — a diagram of a system, a chart of numbers over time, a model the reader can change, a comparison with a pick. Use when the user asks to visualize something, or when an answer outgrows chat.
|
|
4
4
|
argument-hint: '[what to present]'
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,30 +8,35 @@ argument-hint: '[what to present]'
|
|
|
8
8
|
|
|
9
9
|
Present this: $ARGUMENTS. With no argument, present the subject of this conversation.
|
|
10
10
|
|
|
11
|
-
The page is a document to read: its text and static exhibits are in the HTML, and it works offline. Two leading words run through it: the **pick** is the option the page recommends
|
|
11
|
+
The page is a document to read: its text and static exhibits are in the HTML, and it works offline. Two leading words run through it: the **pick** is the option the page recommends, and an **exhibit** is the one table, figure or block that proves a section's claim. A page with no options has no pick: never invent one to recommend.
|
|
12
|
+
|
|
13
|
+
The content sets the page's shape. The template gives the theme and a bare frame; [blocks.html](blocks.html) holds ready parts that share the theme, and a page takes only the parts its content has.
|
|
12
14
|
|
|
13
15
|
## Steps
|
|
14
16
|
|
|
15
|
-
1. **
|
|
16
|
-
2. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
1. **Choose the form.** Before you open the template, name the content's shape (a system, a process, a timeline, a comparison, numbers that change, a set of findings) and the page's main exhibit: the one picture a reader remembers. State both in one line. The form makes the claim obvious at a glance. When the content has parts that connect, steps in order or states that change, the main exhibit is a diagram. Else a chart, an annotated timeline, small multiples, a working model or a ready part, whichever proves the claim.
|
|
18
|
+
2. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`. Leave the template's `<style>` block as it is, because it holds the theme tokens, and put your own CSS in a second `<style>` after it.
|
|
19
|
+
3. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point. A page has a title, a lead, sections and, when it cites, sources; every other part is there only when the content has that shape:
|
|
20
|
+
1. `<title>` and `<h1>`: a name of 2 to 5 words. `.kicker` and `.sub` (the question, in one line) when they add something.
|
|
21
|
+
2. `.lead`: the answer, short enough that a reader who stops here has it. When the page has a pick, name it in `<b>`; else `<b>` holds the main finding, or nothing.
|
|
22
|
+
3. A lineup when the page compares options, stat tiles when it has a few headline numbers.
|
|
23
|
+
4. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and the short text that supports it.
|
|
24
|
+
5. Close with what to do next, when there is a next step, and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
|
|
25
|
+
4. **Build each exhibit.** Each section's claim gets the form that makes it obvious, as in step 1: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map, or a ready part. 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 is 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.
|
|
26
|
+
5. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when a reader who sees only a thumbnail can tell what the page is about, 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.
|
|
27
|
+
6. **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.
|
|
24
28
|
|
|
25
29
|
## Principles
|
|
26
30
|
|
|
27
31
|
Every exhibit, ready or new, keeps these:
|
|
28
32
|
|
|
29
|
-
- **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. In a chart that compares options, the pick's series keeps its series color and the accent goes on its label or end point. The rest stays muted. Status colors mean status.
|
|
33
|
+
- **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. In a chart that compares options, the pick's series keeps its series color and the accent goes on its label or end point. With no pick, the accent marks the one thing the claim is about, or nothing. The rest stays muted. Status colors mean status.
|
|
30
34
|
- **Chart data takes series colors**: `--color-series-1` for the main series, `--color-text-muted` or `--color-series-3` for a second, up to 8 series; skip `--color-series-2`, which is the accent's hue; past that, fold the tail into "Other" or split into small multiples. The accent can sit close to `--color-danger`, so a whole data series in it reads as an alarm.
|
|
31
35
|
- **Tokens color everything**: `var(--color-*)`, tints by `color-mix(in srgb, var(--color-accent) 12%, var(--color-bg))`. Then the page follows light, dark and a host's theme with no script.
|
|
32
36
|
- **Fit**: an SVG has a `viewBox` up to 960 wide, no `width` attribute, text of 12 units or more. A flow runs left to right when it has 6 steps or fewer.
|
|
33
37
|
- **Remove test**: each node, arrow, label and decoration stays only if removing it loses meaning. A diagram past 9 nodes or 12 arrows splits into an overview and a detail. Each SVG has `role="img"` and `aria-labelledby` pointing at its `<title>`, with a per-figure id prefix.
|
|
34
38
|
- **Real**: real names, numbers and `file:line`, estimates and invented data marked, each claim cited. Repeated marks, such as small multiples, are generated while you write the page.
|
|
39
|
+
- **Structure follows the content**: the sections, their order and the parts in them come from the data and the question, so two pages on different topics have different shapes. A part the content does not call for stays out: stat tiles with no headline numbers, next steps nobody asked for, a lineup with no options.
|
|
35
40
|
|
|
36
41
|
## Interactive exhibits
|
|
37
42
|
|
|
@@ -45,7 +50,7 @@ Every exhibit, ready or new, keeps these:
|
|
|
45
50
|
|
|
46
51
|
## Blocks
|
|
47
52
|
|
|
48
|
-
Ready parts in the template's CSS
|
|
53
|
+
Ready parts in the template's CSS. [blocks.html](blocks.html) shows the markup of each; read the rows you use, in the order the story needs.
|
|
49
54
|
|
|
50
55
|
| Block | Markup | Good for |
|
|
51
56
|
|---|---|---|
|
|
@@ -62,6 +67,7 @@ Ready parts in the template's CSS; its body has one of each.
|
|
|
62
67
|
| Callout | `div.callout` | A warning |
|
|
63
68
|
| Steps | `ol.steps` | What to do next |
|
|
64
69
|
| Tag, dot, legend | `span.tag`, `span.dot`, `.legend > span > i` | Labels and keys |
|
|
65
|
-
| Sources |
|
|
70
|
+
| 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` |
|
|
71
|
+
| Citation | `a.cite[href="#src-n"]` holding `[n]` | An in-text marker that jumps to its source row |
|
|
66
72
|
|
|
67
73
|
`data-tone` takes `accent`, `info`, `success`, `warning`, `danger` or `muted` on any block, legend keys included.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!-- Markup of each ready part; the CSS is in template.html. Copy a part only when the content has its shape. -->
|
|
2
|
+
|
|
3
|
+
<!-- Kicker and sub -->
|
|
4
|
+
<p class="kicker">Ticket or context · kind of page</p>
|
|
5
|
+
<p class="sub">The question this page answers.</p>
|
|
6
|
+
|
|
7
|
+
<!-- Lineup: a page that compares options; data-tone="accent" on the pick -->
|
|
8
|
+
<div class="lineup">
|
|
9
|
+
<div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
|
|
10
|
+
<div><b>2</b><span>Option B</span><small>too static</small></div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<!-- Stat tiles: a few headline numbers -->
|
|
14
|
+
<div class="kpis">
|
|
15
|
+
<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>
|
|
16
|
+
<div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Section with a label -->
|
|
20
|
+
<section>
|
|
21
|
+
<p class="eyebrow">Overview</p>
|
|
22
|
+
<h2>A heading is a claim that can be false</h2>
|
|
23
|
+
</section>
|
|
24
|
+
|
|
25
|
+
<!-- Cards: trade-offs of options -->
|
|
26
|
+
<div class="cards">
|
|
27
|
+
<div class="card" data-tone="accent">
|
|
28
|
+
<h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
|
|
29
|
+
<p class="verdict">The verdict, in one line.</p>
|
|
30
|
+
<p>What it is. <a class="cite" href="#src-2">[2]</a></p>
|
|
31
|
+
<pre>on 429: wait, then retry</pre>
|
|
32
|
+
<ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- Table and heatmap cells -->
|
|
37
|
+
<div class="table"><table>
|
|
38
|
+
<thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<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>
|
|
41
|
+
<tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
|
|
42
|
+
<tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
|
|
43
|
+
</tbody>
|
|
44
|
+
</table></div>
|
|
45
|
+
|
|
46
|
+
<!-- Points: findings, each led by its claim -->
|
|
47
|
+
<ul class="points">
|
|
48
|
+
<li><b>A point leads with its claim.</b> The support.</li>
|
|
49
|
+
<li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
|
|
50
|
+
</ul>
|
|
51
|
+
|
|
52
|
+
<!-- Callout -->
|
|
53
|
+
<div class="callout" data-tone="warning"><p>A warning.</p></div>
|
|
54
|
+
|
|
55
|
+
<!-- Steps: what to do next -->
|
|
56
|
+
<ol class="steps"><li>First action.</li><li>Second action.</li></ol>
|
|
57
|
+
|
|
58
|
+
<!-- Legend -->
|
|
59
|
+
<div class="legend"><span><i data-tone="accent"></i>Pick</span><span><i></i>Other</span></div>
|
|
60
|
+
|
|
61
|
+
<!-- Sources with a closing line -->
|
|
62
|
+
<div class="sources">
|
|
63
|
+
<ol>
|
|
64
|
+
<li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
|
|
65
|
+
<li id="src-2"><a href="https://example.com">Title of the page</a><span>the retry on 429</span></li>
|
|
66
|
+
</ol>
|
|
67
|
+
<p>Estimates are marked as estimates.</p>
|
|
68
|
+
</div>
|
|
@@ -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: "
|
|
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:
|
|
135
|
-
.sources
|
|
136
|
-
.sources
|
|
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; }
|
|
@@ -148,68 +158,22 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
|
|
|
148
158
|
</head>
|
|
149
159
|
<body>
|
|
150
160
|
<main>
|
|
151
|
-
<p class="kicker">Ticket or context · kind of page</p>
|
|
152
161
|
<h1>Page Title</h1>
|
|
153
|
-
<p class="
|
|
154
|
-
<p class="lead">The answer in one or two sentences, with <b>the pick</b> named, so a reader who stops here has it.</p>
|
|
155
|
-
|
|
156
|
-
<div class="lineup">
|
|
157
|
-
<div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
|
|
158
|
-
<div><b>2</b><span>Option B</span><small>too static</small></div>
|
|
159
|
-
<div><b>3</b><span>Option C</span><small>separate project</small></div>
|
|
160
|
-
</div>
|
|
161
|
-
|
|
162
|
-
<div class="kpis">
|
|
163
|
-
<div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes [1]</small></div>
|
|
164
|
-
<div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
|
|
165
|
-
</div>
|
|
162
|
+
<p class="lead">The answer, with <b>the main finding</b> named.</p>
|
|
166
163
|
|
|
167
164
|
<section>
|
|
168
|
-
<
|
|
169
|
-
<h2>Each heading is a claim that can be true or false</h2>
|
|
165
|
+
<h2>A heading is a claim that can be false</h2>
|
|
170
166
|
<figure class="wide">
|
|
171
|
-
<svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the
|
|
172
|
-
<figcaption>
|
|
167
|
+
<svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the figure shows</title></svg>
|
|
168
|
+
<figcaption>What to notice.</figcaption>
|
|
173
169
|
</figure>
|
|
174
170
|
</section>
|
|
175
171
|
|
|
176
|
-
<
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
|
|
182
|
-
<p class="verdict">The verdict, in one line.</p>
|
|
183
|
-
<p>What it is, in one sentence.</p>
|
|
184
|
-
<pre>on 429: wait, then retry</pre>
|
|
185
|
-
<ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
</section>
|
|
189
|
-
|
|
190
|
-
<section>
|
|
191
|
-
<p class="eyebrow">Compare</p>
|
|
192
|
-
<h2>A table holds the matrix</h2>
|
|
193
|
-
<div class="table"><table>
|
|
194
|
-
<thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
|
|
195
|
-
<tbody>
|
|
196
|
-
<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>
|
|
198
|
-
</tbody>
|
|
199
|
-
</table></div>
|
|
200
|
-
<ul class="points">
|
|
201
|
-
<li><b>A point leads with its claim.</b> One or two sentences of support.</li>
|
|
202
|
-
<li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
|
|
203
|
-
</ul>
|
|
204
|
-
</section>
|
|
205
|
-
|
|
206
|
-
<section>
|
|
207
|
-
<p class="eyebrow">Next</p>
|
|
208
|
-
<h2>Steps say what to do next</h2>
|
|
209
|
-
<ol class="steps"><li>First action.</li><li>Second action.</li></ol>
|
|
210
|
-
</section>
|
|
211
|
-
|
|
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>
|
|
172
|
+
<div class="sources">
|
|
173
|
+
<ol>
|
|
174
|
+
<li id="src-1"><code>path/to/file.ts:42</code><span>what it backs</span></li>
|
|
175
|
+
</ol>
|
|
176
|
+
</div>
|
|
213
177
|
</main>
|
|
214
178
|
</body>
|
|
215
179
|
</html>
|
package/public/app.js
CHANGED
|
@@ -2604,9 +2604,18 @@ function replyLocateHint(timestamp, sid) {
|
|
|
2604
2604
|
return `If that reply is not in your context, it is in ${transcript}, the assistant entry with timestamp ${timestamp}.`;
|
|
2605
2605
|
}
|
|
2606
2606
|
|
|
2607
|
+
let msgDetailFromTerminal = false;
|
|
2608
|
+
|
|
2607
2609
|
function closeMsgDetailModal() {
|
|
2608
2610
|
unmountMsgReview();
|
|
2609
2611
|
hideModalOverlay('msg-detail-modal');
|
|
2612
|
+
// A task later, so a modal opened right after this one (View Agent) keeps the focus.
|
|
2613
|
+
if (msgDetailFromTerminal) {
|
|
2614
|
+
setTimeout(() => {
|
|
2615
|
+
if (!isAnyModalOpen() && wantsTerminal()) focusTerminalPane();
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
msgDetailFromTerminal = false;
|
|
2610
2619
|
msgDetailFollowLatest = false;
|
|
2611
2620
|
waitingDetailAutoOpened = false;
|
|
2612
2621
|
// Drop the message highlight on close, mirroring task-card behavior.
|
|
@@ -3918,7 +3927,7 @@ function renderSessions() {
|
|
|
3918
3927
|
const renderSessionCard = (session) => {
|
|
3919
3928
|
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
|
|
3920
3929
|
const total = session.taskCount;
|
|
3921
|
-
const
|
|
3930
|
+
const pct = (n) => Math.round((n / total) * 100);
|
|
3922
3931
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3923
3932
|
const isLive = isSessionLive(session);
|
|
3924
3933
|
const sessionName = sessionDisplayName(session);
|
|
@@ -3951,7 +3960,7 @@ function renderSessions() {
|
|
|
3951
3960
|
const sid = escAttrJs(session.id);
|
|
3952
3961
|
const progressHtml =
|
|
3953
3962
|
total > 0
|
|
3954
|
-
? `<div class="session-progress" title="${escapeHtml(session.
|
|
3963
|
+
? `<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
3964
|
: '';
|
|
3956
3965
|
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3957
3966
|
return `
|
|
@@ -6391,6 +6400,12 @@ const SHORTCUT_TABS = [
|
|
|
6391
6400
|
title: 'Tabs',
|
|
6392
6401
|
rows: [
|
|
6393
6402
|
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
6403
|
+
{
|
|
6404
|
+
keys: ['Ctrl', 'Alt', '1…9'],
|
|
6405
|
+
combo: true,
|
|
6406
|
+
yields: 'ctrl+alt+1',
|
|
6407
|
+
label: 'Go to tab or group N (the board is 1)',
|
|
6408
|
+
},
|
|
6394
6409
|
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
6395
6410
|
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
6396
6411
|
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
@@ -6542,6 +6557,8 @@ function termKbd() {
|
|
|
6542
6557
|
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
6543
6558
|
const live = row.action && keyLabel(row.action);
|
|
6544
6559
|
if (live) return live;
|
|
6560
|
+
// `yields`: the row's key gives way when the hub binds that combo.
|
|
6561
|
+
if (row.yields && hub.forwardCombos().includes(row.yields)) return [];
|
|
6545
6562
|
if (!mac) return row.keys;
|
|
6546
6563
|
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
6547
6564
|
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
@@ -7548,16 +7565,19 @@ function shiftOnly(e, code) {
|
|
|
7548
7565
|
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7549
7566
|
}
|
|
7550
7567
|
|
|
7568
|
+
// xterm runs in another process, so blur() in the board leaves it focused and the modal's arrow keys go
|
|
7569
|
+
// to it. Focusing a board element takes the focus.
|
|
7551
7570
|
function toggleLastMessage() {
|
|
7552
7571
|
if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7553
7572
|
closeMsgDetailModal();
|
|
7554
|
-
|
|
7555
|
-
msgDetailFollowLatest = true;
|
|
7556
|
-
showWaitingDetail();
|
|
7557
|
-
} else if (currentMessages.length) {
|
|
7558
|
-
msgDetailFollowLatest = true;
|
|
7559
|
-
showMsgDetail(currentMessages.length - 1);
|
|
7573
|
+
return;
|
|
7560
7574
|
}
|
|
7575
|
+
if (!isWaitingFresh() && !currentMessages.length) return;
|
|
7576
|
+
msgDetailFromTerminal = terminalPaneFocused();
|
|
7577
|
+
msgDetailFollowLatest = true;
|
|
7578
|
+
if (isWaitingFresh()) showWaitingDetail();
|
|
7579
|
+
else showMsgDetail(currentMessages.length - 1);
|
|
7580
|
+
if (msgDetailFromTerminal) document.querySelector('#msg-detail-modal .msg-detail-dialog').focus();
|
|
7561
7581
|
}
|
|
7562
7582
|
|
|
7563
7583
|
function dismissSessionFromList(sid) {
|
|
@@ -7825,6 +7845,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7825
7845
|
}
|
|
7826
7846
|
return;
|
|
7827
7847
|
}
|
|
7848
|
+
// The hub binds Ctrl+Alt+digit on macOS for its app keys.
|
|
7849
|
+
if (hubModDown(e) && /^Digit[1-9]$/.test(e.code) && !hub.forwards(e) && selectPaneByNumber(Number(e.code.slice(5)))) {
|
|
7850
|
+
e.preventDefault();
|
|
7851
|
+
focusActivePane();
|
|
7852
|
+
return;
|
|
7853
|
+
}
|
|
7828
7854
|
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
7829
7855
|
e.preventDefault();
|
|
7830
7856
|
return;
|
|
@@ -8631,6 +8657,7 @@ const PANE_CLAIMS = [
|
|
|
8631
8657
|
...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8632
8658
|
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
8633
8659
|
...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
|
|
8660
|
+
...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
|
|
8634
8661
|
];
|
|
8635
8662
|
|
|
8636
8663
|
// Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
|
|
@@ -10163,6 +10190,12 @@ function setupEventSource() {
|
|
|
10163
10190
|
handleSessionOpenEvent(data);
|
|
10164
10191
|
}
|
|
10165
10192
|
|
|
10193
|
+
// Alone, the page would open a tab without a user gesture, which the browser blocks; the
|
|
10194
|
+
// CLI prints the link instead.
|
|
10195
|
+
if (data.type === 'session:cost' && hub.inHub) {
|
|
10196
|
+
openCost(data.id, data.agent);
|
|
10197
|
+
}
|
|
10198
|
+
|
|
10166
10199
|
if (data.type === 'session:pin') {
|
|
10167
10200
|
handleSessionPinEvent(data);
|
|
10168
10201
|
}
|
|
@@ -12953,7 +12986,9 @@ function openFolderInEditor(folder, file) {
|
|
|
12953
12986
|
postAndToast('/api/open-folder', body, 'folder');
|
|
12954
12987
|
}
|
|
12955
12988
|
|
|
12956
|
-
function openCost(sessionId) {
|
|
12989
|
+
function openCost(sessionId, agentId) {
|
|
12990
|
+
if (sessionId && agentId && hub.can('agent.cost'))
|
|
12991
|
+
return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
|
|
12957
12992
|
hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
|
|
12958
12993
|
}
|
|
12959
12994
|
|
|
@@ -13237,6 +13272,7 @@ function setActivePaneId(sid, id) {
|
|
|
13237
13272
|
if (getActivePaneId(sid) === id) return;
|
|
13238
13273
|
paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
|
|
13239
13274
|
notePairFocus(sid, id);
|
|
13275
|
+
noteGroupFocus(sid, id);
|
|
13240
13276
|
}
|
|
13241
13277
|
|
|
13242
13278
|
// {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
@@ -13297,18 +13333,75 @@ function neighborPair(sid, id, pairs) {
|
|
|
13297
13333
|
return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
|
|
13298
13334
|
}
|
|
13299
13335
|
|
|
13300
|
-
//
|
|
13301
|
-
|
|
13336
|
+
// {[sessionId]: [{id, name, color, ids, closed, last}]}; `ids` are pane ids, `last` the one shown
|
|
13337
|
+
// last. Like pairs, groups live in this browser tab only. A group shows at the place of its first
|
|
13338
|
+
// tab, so grouping leaves the server's order alone; a drag saves the order on screen.
|
|
13339
|
+
const PANE_GROUPS_KEY = 'pane-groups';
|
|
13340
|
+
const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
|
|
13341
|
+
let paneGroups = loadPaneState(PANE_GROUPS_KEY);
|
|
13342
|
+
|
|
13343
|
+
// Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
|
|
13344
|
+
function getPaneGroups(sid) {
|
|
13345
|
+
if (!paneGroups[sid]) return [];
|
|
13346
|
+
const live = new Set(paneLayout(sid).panes.map((p) => p.id));
|
|
13347
|
+
const taken = new Set();
|
|
13348
|
+
const groups = [];
|
|
13349
|
+
for (const g of paneGroups[sid]) {
|
|
13350
|
+
const ids = g.ids.filter((id) => live.has(id) && !taken.has(id));
|
|
13351
|
+
for (const id of ids) taken.add(id);
|
|
13352
|
+
const color = PANE_GROUP_COLORS.includes(g.color) ? g.color : PANE_GROUP_COLORS[0];
|
|
13353
|
+
if (ids.length) groups.push({ id: g.id, name: g.name || '', color, ids, closed: !!g.closed, last: g.last });
|
|
13354
|
+
}
|
|
13355
|
+
return groups;
|
|
13356
|
+
}
|
|
13357
|
+
|
|
13358
|
+
function setPaneGroups(sid, groups) {
|
|
13359
|
+
paneGroups = savePaneState(PANE_GROUPS_KEY, paneGroups, sid, groups.length ? groups : null);
|
|
13360
|
+
}
|
|
13361
|
+
|
|
13362
|
+
// A pair is in the group of either side, and the board is in none.
|
|
13363
|
+
function groupOfTabs(groups, ids) {
|
|
13364
|
+
return ids.includes('board') ? null : groups.find((g) => ids.some((t) => g.ids.includes(t))) || null;
|
|
13365
|
+
}
|
|
13366
|
+
|
|
13367
|
+
function noteGroupFocus(sid, id) {
|
|
13368
|
+
const pair = pairOf(sid, id);
|
|
13369
|
+
const group = groupOfTabs(getPaneGroups(sid), pair ? [pair.l, pair.r] : [id]);
|
|
13370
|
+
if (group && (group.closed || group.last !== id)) updatePaneGroup(sid, group.id, { closed: false, last: id });
|
|
13371
|
+
}
|
|
13372
|
+
|
|
13373
|
+
// The strip as the number keys count it, {group, items}: a group is one stop, open or collapsed.
|
|
13374
|
+
// An item is {ids, pair, group}: a lone tab, or a pair at the place of its first tab. A group's
|
|
13375
|
+
// items follow its first one.
|
|
13376
|
+
function paneStripStops(sid) {
|
|
13302
13377
|
const pairs = getPanePairs(sid);
|
|
13303
|
-
const
|
|
13378
|
+
const groups = getPaneGroups(sid);
|
|
13379
|
+
const stops = [];
|
|
13380
|
+
const groupStops = new Map();
|
|
13381
|
+
const seen = new Set();
|
|
13304
13382
|
for (const id of paneTabIds(sid)) {
|
|
13305
13383
|
const pair = pairs.find((p) => splitSideOf(p, id)) || null;
|
|
13306
|
-
if (
|
|
13307
|
-
|
|
13384
|
+
if (pair && seen.has(pair)) continue;
|
|
13385
|
+
if (pair) seen.add(pair);
|
|
13386
|
+
const ids = pair ? [pair.l, pair.r] : [id];
|
|
13387
|
+
const group = groupOfTabs(groups, ids);
|
|
13388
|
+
const item = { ids, pair, group };
|
|
13389
|
+
const stop = group && groupStops.get(group);
|
|
13390
|
+
if (stop) stop.items.push(item);
|
|
13391
|
+
else {
|
|
13392
|
+
stops.push({ group, items: [item] });
|
|
13393
|
+
if (group) groupStops.set(group, stops.at(-1));
|
|
13394
|
+
}
|
|
13308
13395
|
}
|
|
13309
|
-
return
|
|
13396
|
+
return stops;
|
|
13310
13397
|
}
|
|
13311
13398
|
|
|
13399
|
+
const paneStripItems = (sid) => paneStripStops(sid).flatMap((s) => s.items);
|
|
13400
|
+
|
|
13401
|
+
// The items a click or a key can reach: a collapsed group hides its tabs.
|
|
13402
|
+
const isOpenItem = (it) => !it.group?.closed;
|
|
13403
|
+
const openStripItems = (sid) => paneStripItems(sid).filter(isOpenItem);
|
|
13404
|
+
|
|
13312
13405
|
function unpairPanes(sid, pair, keep) {
|
|
13313
13406
|
setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
|
|
13314
13407
|
if (keep) setActivePaneId(sid, keep);
|
|
@@ -13502,7 +13595,7 @@ function paneMenuLabel(pane, label) {
|
|
|
13502
13595
|
|
|
13503
13596
|
function renderPaneTabs(sid, layout, split) {
|
|
13504
13597
|
// A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
|
|
13505
|
-
if (paneDragId ||
|
|
13598
|
+
if (paneDragId || stripRename) return;
|
|
13506
13599
|
const active = getActivePaneId(sid);
|
|
13507
13600
|
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
13508
13601
|
const tab = (id, cls, inner, tip) =>
|
|
@@ -13531,9 +13624,17 @@ function renderPaneTabs(sid, layout, split) {
|
|
|
13531
13624
|
pair
|
|
13532
13625
|
? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
|
|
13533
13626
|
: tabHtml(ids[0]);
|
|
13534
|
-
const
|
|
13535
|
-
|
|
13536
|
-
|
|
13627
|
+
const groupHtml = (g, items) => {
|
|
13628
|
+
const n = items.flatMap((it) => it.ids).length;
|
|
13629
|
+
const tip = g.closed ? `Expand group (${n} tabs)` : 'Collapse group';
|
|
13630
|
+
const label =
|
|
13631
|
+
(g.name ? `<span>${escapeHtml(g.name)}</span>` : '') +
|
|
13632
|
+
(g.closed ? `<span class="pane-group-count">${n}</span>` : '');
|
|
13633
|
+
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>`;
|
|
13634
|
+
};
|
|
13635
|
+
const [first, ...rest] = paneStripStops(sid);
|
|
13636
|
+
const pin = itemHtml(first.items[0]) + (rest.length ? '<span class="pane-sep"></span>' : '');
|
|
13637
|
+
const html = rest.map(({ group, items }) => (group ? groupHtml(group, items) : itemHtml(items[0]))).join('');
|
|
13537
13638
|
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
13538
13639
|
if (pin + html === paneTabsHtml) return;
|
|
13539
13640
|
paneTabsHtml = pin + html;
|
|
@@ -13901,17 +14002,90 @@ function cyclePane(step) {
|
|
|
13901
14002
|
return true;
|
|
13902
14003
|
}
|
|
13903
14004
|
|
|
14005
|
+
function selectStripItem({ ids, pair }) {
|
|
14006
|
+
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
14007
|
+
}
|
|
14008
|
+
|
|
14009
|
+
function selectPaneByNumber(n) {
|
|
14010
|
+
const sid = paneSessionId();
|
|
14011
|
+
if (!sid || wantsTerminal()) return false;
|
|
14012
|
+
const stop = paneStripStops(sid)[n - 1];
|
|
14013
|
+
if (stop) selectStripItem(stop.items.find((it) => it.ids.includes(stop.group?.last)) || stop.items[0]);
|
|
14014
|
+
return true;
|
|
14015
|
+
}
|
|
14016
|
+
|
|
13904
14017
|
function cyclePaneStrip(step) {
|
|
13905
14018
|
const sid = paneSessionId();
|
|
13906
14019
|
if (!sid || wantsTerminal()) return false;
|
|
13907
14020
|
const active = getActivePaneId(sid);
|
|
13908
|
-
const items =
|
|
14021
|
+
const items = openStripItems(sid);
|
|
13909
14022
|
const i = items.findIndex((it) => it.ids.includes(active));
|
|
13910
|
-
|
|
13911
|
-
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
14023
|
+
selectStripItem(items[(i + step + items.length) % items.length]);
|
|
13912
14024
|
return true;
|
|
13913
14025
|
}
|
|
13914
14026
|
|
|
14027
|
+
function updatePaneGroup(sid, groupId, change) {
|
|
14028
|
+
const groups = getPaneGroups(sid);
|
|
14029
|
+
const group = groups.find((g) => g.id === groupId);
|
|
14030
|
+
if (!group) return;
|
|
14031
|
+
Object.assign(group, change);
|
|
14032
|
+
setPaneGroups(sid, groups);
|
|
14033
|
+
}
|
|
14034
|
+
|
|
14035
|
+
// `to` is a group id, 'new', or null to take the tab out of its group. A split moves as one.
|
|
14036
|
+
function groupPaneTab(id, to) {
|
|
14037
|
+
const sid = paneSessionId();
|
|
14038
|
+
const item = sid && paneStripItems(sid).find((it) => it.ids.includes(id));
|
|
14039
|
+
if (!item || item.ids.includes('board')) return;
|
|
14040
|
+
const groups = getPaneGroups(sid);
|
|
14041
|
+
for (const g of groups) g.ids = g.ids.filter((t) => !item.ids.includes(t));
|
|
14042
|
+
let target = groups.find((g) => g.id === to);
|
|
14043
|
+
if (to === 'new') {
|
|
14044
|
+
const used = new Set(groups.filter((g) => g.ids.length).map((g) => g.color));
|
|
14045
|
+
const color =
|
|
14046
|
+
PANE_GROUP_COLORS.find((c) => !used.has(c)) || PANE_GROUP_COLORS[groups.length % PANE_GROUP_COLORS.length];
|
|
14047
|
+
target = { id: `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, name: '', color, ids: [] };
|
|
14048
|
+
groups.push(target);
|
|
14049
|
+
}
|
|
14050
|
+
if (target) Object.assign(target, { ids: [...target.ids, ...item.ids], closed: false });
|
|
14051
|
+
setPaneGroups(sid, groups);
|
|
14052
|
+
syncPanes();
|
|
14053
|
+
if (to === 'new') beginPaneGroupRename(target.id);
|
|
14054
|
+
}
|
|
14055
|
+
|
|
14056
|
+
// A collapsed group cannot hold the tab on screen, so collapsing it moves to the next open item.
|
|
14057
|
+
function togglePaneGroup(groupId) {
|
|
14058
|
+
const sid = paneSessionId();
|
|
14059
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14060
|
+
if (!group) return;
|
|
14061
|
+
const closing = !group.closed;
|
|
14062
|
+
updatePaneGroup(sid, groupId, { closed: closing });
|
|
14063
|
+
const items = paneStripItems(sid);
|
|
14064
|
+
const i = items.findIndex((it) => it.ids.includes(getActivePaneId(sid)));
|
|
14065
|
+
if (!closing || items[i]?.group?.id !== groupId) {
|
|
14066
|
+
syncPanes();
|
|
14067
|
+
return;
|
|
14068
|
+
}
|
|
14069
|
+
selectStripItem(items.slice(i).find(isOpenItem) || items.slice(0, i).findLast(isOpenItem));
|
|
14070
|
+
}
|
|
14071
|
+
|
|
14072
|
+
function ungroupPanes(groupId) {
|
|
14073
|
+
const sid = paneSessionId();
|
|
14074
|
+
if (!sid) return;
|
|
14075
|
+
setPaneGroups(
|
|
14076
|
+
sid,
|
|
14077
|
+
getPaneGroups(sid).filter((g) => g.id !== groupId),
|
|
14078
|
+
);
|
|
14079
|
+
syncPanes();
|
|
14080
|
+
}
|
|
14081
|
+
|
|
14082
|
+
function colorPaneGroup(groupId, color) {
|
|
14083
|
+
const sid = paneSessionId();
|
|
14084
|
+
if (!sid) return;
|
|
14085
|
+
updatePaneGroup(sid, groupId, { color });
|
|
14086
|
+
syncPanes();
|
|
14087
|
+
}
|
|
14088
|
+
|
|
13915
14089
|
function isMenuKey(e) {
|
|
13916
14090
|
return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
13917
14091
|
}
|
|
@@ -14057,44 +14231,105 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
14057
14231
|
await refreshCurrentView();
|
|
14058
14232
|
}
|
|
14059
14233
|
|
|
14060
|
-
|
|
14234
|
+
// The save function of the tab or group name being edited.
|
|
14235
|
+
let stripRename = null;
|
|
14236
|
+
|
|
14237
|
+
function beginStripRename(el, { className, value, label, maxLength, save }) {
|
|
14238
|
+
stripRename = save;
|
|
14239
|
+
const input = document.createElement('input');
|
|
14240
|
+
input.className = `strip-rename ${className}`;
|
|
14241
|
+
input.value = value;
|
|
14242
|
+
if (maxLength) input.maxLength = maxLength;
|
|
14243
|
+
input.setAttribute('aria-label', label);
|
|
14244
|
+
el.replaceWith(input);
|
|
14245
|
+
input.focus();
|
|
14246
|
+
input.select();
|
|
14247
|
+
}
|
|
14248
|
+
|
|
14249
|
+
async function endStripRename(input, save) {
|
|
14250
|
+
const onSave = stripRename;
|
|
14251
|
+
if (!onSave) return;
|
|
14252
|
+
stripRename = null;
|
|
14253
|
+
paneTabsHtml = '';
|
|
14254
|
+
if (save) await onSave(input.value.trim());
|
|
14255
|
+
syncPanes();
|
|
14256
|
+
}
|
|
14061
14257
|
|
|
14062
14258
|
function beginPaneRename(id) {
|
|
14063
14259
|
const pane = paneById(id);
|
|
14064
14260
|
const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
|
|
14065
14261
|
const title = tab?.querySelector('.pane-title');
|
|
14066
14262
|
if (!pane || !title) return;
|
|
14067
|
-
paneRenameId = id;
|
|
14068
14263
|
tab.draggable = false;
|
|
14069
|
-
|
|
14070
|
-
|
|
14071
|
-
|
|
14072
|
-
|
|
14073
|
-
|
|
14074
|
-
|
|
14075
|
-
|
|
14264
|
+
beginStripRename(title, {
|
|
14265
|
+
className: 'pane-title-input',
|
|
14266
|
+
value: pane.title,
|
|
14267
|
+
label: 'Pane title',
|
|
14268
|
+
save: async (text) => {
|
|
14269
|
+
const sid = paneSessionId();
|
|
14270
|
+
if (!sid || !paneById(id) || text === paneById(id).title) return;
|
|
14271
|
+
const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { title: text });
|
|
14272
|
+
if (res.error) showToast(res.error, 'error');
|
|
14273
|
+
else applyPaneLayout(sid, res.layout);
|
|
14274
|
+
},
|
|
14275
|
+
});
|
|
14076
14276
|
}
|
|
14077
14277
|
|
|
14078
|
-
|
|
14079
|
-
|
|
14080
|
-
|
|
14081
|
-
paneRenameId = null;
|
|
14082
|
-
paneTabsHtml = '';
|
|
14278
|
+
const PANE_GROUP_NAME_MAX = 40;
|
|
14279
|
+
|
|
14280
|
+
function beginPaneGroupRename(groupId) {
|
|
14083
14281
|
const sid = paneSessionId();
|
|
14084
|
-
const
|
|
14085
|
-
const
|
|
14086
|
-
if (
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
|
|
14090
|
-
|
|
14091
|
-
|
|
14092
|
-
|
|
14093
|
-
|
|
14282
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14283
|
+
const chip = paneStrip.querySelector(`[data-group="${CSS.escape(groupId)}"]`);
|
|
14284
|
+
if (!group || !chip) return;
|
|
14285
|
+
beginStripRename(chip, {
|
|
14286
|
+
className: 'pane-group-input',
|
|
14287
|
+
value: group.name,
|
|
14288
|
+
label: 'Group name',
|
|
14289
|
+
maxLength: PANE_GROUP_NAME_MAX,
|
|
14290
|
+
save: (name) => updatePaneGroup(sid, groupId, { name: name.slice(0, PANE_GROUP_NAME_MAX) }),
|
|
14291
|
+
});
|
|
14094
14292
|
}
|
|
14095
14293
|
|
|
14096
|
-
const paneMenuItem = (act, label) =>
|
|
14097
|
-
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
14294
|
+
const paneMenuItem = (act, label, arg) =>
|
|
14295
|
+
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}"${arg == null ? '' : ` data-arg="${escapeHtml(arg)}"`}>${label}</button>`;
|
|
14296
|
+
const paneGroupStyle = (color) => escapeHtml(`--g: var(--owner-${color})`);
|
|
14297
|
+
const paneGroupDot = (g) => `<span class="pane-menu-dot" style="${paneGroupStyle(g.color)}"></span>`;
|
|
14298
|
+
|
|
14299
|
+
function paneGroupMenuItems(id) {
|
|
14300
|
+
const sid = paneSessionId();
|
|
14301
|
+
const item = paneStripItems(sid).find((it) => it.ids.includes(id));
|
|
14302
|
+
if (!item || item.ids.includes('board')) return '';
|
|
14303
|
+
const others = getPaneGroups(sid).filter((g) => g.id !== item.group?.id);
|
|
14304
|
+
return (
|
|
14305
|
+
paneMenuItem('group', 'Add to new group', 'new') +
|
|
14306
|
+
others
|
|
14307
|
+
.map((g) => paneMenuItem('group', `Add to ${paneGroupDot(g)}${escapeHtml(g.name || 'unnamed group')}`, g.id))
|
|
14308
|
+
.join('') +
|
|
14309
|
+
(item.group ? paneMenuItem('group', 'Remove from group', '') : '')
|
|
14310
|
+
);
|
|
14311
|
+
}
|
|
14312
|
+
|
|
14313
|
+
function openPaneGroupMenu(x, y, groupId) {
|
|
14314
|
+
const sid = paneSessionId();
|
|
14315
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14316
|
+
if (!group) return;
|
|
14317
|
+
const swatches = PANE_GROUP_COLORS.map(
|
|
14318
|
+
(c) =>
|
|
14319
|
+
`<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>`,
|
|
14320
|
+
).join('');
|
|
14321
|
+
showPaneMenu(
|
|
14322
|
+
x,
|
|
14323
|
+
y,
|
|
14324
|
+
group.id,
|
|
14325
|
+
'Group actions',
|
|
14326
|
+
`<div class="pane-menu-label">${escapeHtml(group.name || 'Unnamed group')}</div>` +
|
|
14327
|
+
paneMenuItem('groupRename', 'Rename') +
|
|
14328
|
+
paneMenuItem('groupToggle', group.closed ? 'Expand' : 'Collapse') +
|
|
14329
|
+
`<div class="pane-menu-swatches">${swatches}</div>` +
|
|
14330
|
+
paneMenuItem('ungroup', 'Ungroup'),
|
|
14331
|
+
);
|
|
14332
|
+
}
|
|
14098
14333
|
|
|
14099
14334
|
function openPaneMenu(x, y, id) {
|
|
14100
14335
|
if (id === 'board') {
|
|
@@ -14127,6 +14362,7 @@ function openPaneMenu(x, y, id) {
|
|
|
14127
14362
|
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
14128
14363
|
paneMenuItem('rename', 'Rename') +
|
|
14129
14364
|
paneMenuItem('reload', 'Reload') +
|
|
14365
|
+
paneGroupMenuItems(pane.id) +
|
|
14130
14366
|
paneMenuItem('close', 'Close pane'),
|
|
14131
14367
|
);
|
|
14132
14368
|
return true;
|
|
@@ -14169,6 +14405,11 @@ const PANE_MENU_ACTIONS = {
|
|
|
14169
14405
|
rename: beginPaneRename,
|
|
14170
14406
|
reload: reloadPane,
|
|
14171
14407
|
close: closePane,
|
|
14408
|
+
group: (id, to) => groupPaneTab(id, to || null),
|
|
14409
|
+
groupRename: beginPaneGroupRename,
|
|
14410
|
+
groupToggle: togglePaneGroup,
|
|
14411
|
+
groupColor: colorPaneGroup,
|
|
14412
|
+
ungroup: ungroupPanes,
|
|
14172
14413
|
deleteCompleted: () => deleteBoardTasks(true),
|
|
14173
14414
|
deleteAll: () => deleteBoardTasks(false),
|
|
14174
14415
|
};
|
|
@@ -14358,18 +14599,23 @@ function closePanePop() {
|
|
|
14358
14599
|
}
|
|
14359
14600
|
|
|
14360
14601
|
paneStrip.addEventListener('keydown', (e) => {
|
|
14361
|
-
const input = e.target.closest?.('.
|
|
14602
|
+
const input = e.target.closest?.('.strip-rename');
|
|
14362
14603
|
if (input && (e.key === 'Enter' || e.key === 'Escape')) {
|
|
14363
14604
|
e.preventDefault();
|
|
14364
|
-
|
|
14605
|
+
endStripRename(input, e.key === 'Enter');
|
|
14365
14606
|
}
|
|
14366
14607
|
});
|
|
14367
14608
|
paneStrip.addEventListener('focusout', (e) => {
|
|
14368
|
-
const input = e.target.closest?.('.
|
|
14369
|
-
if (input)
|
|
14609
|
+
const input = e.target.closest?.('.strip-rename');
|
|
14610
|
+
if (input) endStripRename(input, true);
|
|
14370
14611
|
});
|
|
14371
14612
|
paneStrip.addEventListener('click', (e) => {
|
|
14372
|
-
if (e.target.closest('.
|
|
14613
|
+
if (e.target.closest('.strip-rename')) return;
|
|
14614
|
+
const chip = e.target.closest('[data-group]');
|
|
14615
|
+
if (chip) {
|
|
14616
|
+
togglePaneGroup(chip.dataset.group);
|
|
14617
|
+
return;
|
|
14618
|
+
}
|
|
14373
14619
|
const close = e.target.closest('[data-close]');
|
|
14374
14620
|
if (close) {
|
|
14375
14621
|
closePane(close.dataset.close);
|
|
@@ -14385,16 +14631,19 @@ paneStrip.addEventListener('auxclick', (e) => {
|
|
|
14385
14631
|
if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
|
|
14386
14632
|
});
|
|
14387
14633
|
paneStrip.addEventListener('contextmenu', (e) => {
|
|
14634
|
+
const chip = e.target.closest('[data-group]');
|
|
14388
14635
|
const tab = e.target.closest('.pane-tab');
|
|
14389
|
-
if (!tab) return;
|
|
14636
|
+
if (!chip && !tab) return;
|
|
14390
14637
|
e.preventDefault();
|
|
14391
|
-
|
|
14638
|
+
if (chip) openPaneGroupMenu(e.clientX, e.clientY, chip.dataset.group);
|
|
14639
|
+
else openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
|
|
14392
14640
|
});
|
|
14393
14641
|
document.addEventListener('click', (e) => {
|
|
14394
14642
|
const menu = document.getElementById('pane-menu');
|
|
14395
14643
|
if (!menu) return;
|
|
14396
|
-
const
|
|
14397
|
-
|
|
14644
|
+
const item = e.target.closest('.pane-menu-item');
|
|
14645
|
+
const act = item?.dataset.paneAct;
|
|
14646
|
+
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane, item.dataset.arg);
|
|
14398
14647
|
if (act) closePaneMenu();
|
|
14399
14648
|
else if (!menu.contains(e.target)) closePaneMenu(true);
|
|
14400
14649
|
});
|
|
@@ -14470,15 +14719,18 @@ paneStrip.addEventListener('dragstart', (e) => {
|
|
|
14470
14719
|
});
|
|
14471
14720
|
|
|
14472
14721
|
// A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
|
|
14473
|
-
// holds the board and stays put.
|
|
14722
|
+
// holds the board and stays put. A moved item joins the group it lands in, and a drop on a
|
|
14723
|
+
// group's chip adds it to that group. Elsewhere on the strip, a split tab's drop ends its split.
|
|
14474
14724
|
function paneDropAction(e) {
|
|
14475
14725
|
const sid = paneSessionId();
|
|
14476
14726
|
const tab = e.target.closest?.('.pane-tab');
|
|
14727
|
+
const chip = e.target.closest?.('[data-group]');
|
|
14477
14728
|
if (!sid || !paneDragId) return null;
|
|
14478
|
-
if (tab) {
|
|
14729
|
+
if (chip || tab) {
|
|
14479
14730
|
const [fixed, ...movable] = paneStripItems(sid);
|
|
14480
14731
|
const itemOf = (id) => movable.find((it) => it.ids.includes(id));
|
|
14481
14732
|
const from = itemOf(paneDragId);
|
|
14733
|
+
if (chip) return from ? { kind: 'group', sid, el: chip, group: chip.dataset.group } : null;
|
|
14482
14734
|
const to = itemOf(tab.dataset.pane);
|
|
14483
14735
|
if (from && to && from !== to) {
|
|
14484
14736
|
const el = to.pair ? tab.closest('.pane-pair') : tab;
|
|
@@ -14492,9 +14744,11 @@ function paneDropAction(e) {
|
|
|
14492
14744
|
|
|
14493
14745
|
function markPaneDrop(action) {
|
|
14494
14746
|
paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
|
|
14495
|
-
for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
|
|
14496
|
-
|
|
14497
|
-
el.classList.toggle('drop-
|
|
14747
|
+
for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair, .pane-group-chip')) {
|
|
14748
|
+
const on = el === action?.el;
|
|
14749
|
+
el.classList.toggle('drop-in', on && action.kind === 'group');
|
|
14750
|
+
el.classList.toggle('drop-before', on && action.kind === 'move' && !action.after);
|
|
14751
|
+
el.classList.toggle('drop-after', on && action.kind === 'move' && action.after);
|
|
14498
14752
|
}
|
|
14499
14753
|
}
|
|
14500
14754
|
|
|
@@ -14521,11 +14775,15 @@ paneStrip.addEventListener('drop', (e) => {
|
|
|
14521
14775
|
if (!action) return;
|
|
14522
14776
|
e.preventDefault();
|
|
14523
14777
|
const { sid, pair } = action;
|
|
14778
|
+
const id = paneDragId;
|
|
14524
14779
|
const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
|
|
14525
|
-
const keep = shown ? pair[otherSide(splitSideOf(pair,
|
|
14780
|
+
const keep = shown ? pair[otherSide(splitSideOf(pair, id))] : null;
|
|
14526
14781
|
endPaneDrag();
|
|
14527
|
-
if (action.kind === '
|
|
14528
|
-
else
|
|
14782
|
+
if (action.kind === 'group') groupPaneTab(id, action.group);
|
|
14783
|
+
else if (action.kind === 'move') {
|
|
14784
|
+
if (action.from.group?.id !== action.to.group?.id) groupPaneTab(id, action.to.group?.id ?? null);
|
|
14785
|
+
movePaneItem(action);
|
|
14786
|
+
} else unpairPanes(sid, pair, keep);
|
|
14529
14787
|
});
|
|
14530
14788
|
paneDrop.addEventListener('dragover', (e) => {
|
|
14531
14789
|
if (!paneDragId) return;
|
package/public/index.html
CHANGED
|
@@ -430,7 +430,7 @@
|
|
|
430
430
|
|
|
431
431
|
<!-- Message Detail Modal -->
|
|
432
432
|
<div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
|
|
433
|
-
<div class="modal msg-detail-dialog review-host" onclick="event.stopPropagation()">
|
|
433
|
+
<div class="modal msg-detail-dialog review-host" tabindex="-1" onclick="event.stopPropagation()">
|
|
434
434
|
<div class="modal-header">
|
|
435
435
|
<h3 class="modal-title" id="msg-detail-title">Message</h3>
|
|
436
436
|
<div style="display:flex;gap:4px;align-items:center;">
|
package/public/style.css
CHANGED
|
@@ -1204,7 +1204,6 @@ body.booting .boot-splash {
|
|
|
1204
1204
|
|
|
1205
1205
|
/* The live-workflow bar takes the muted fill too: its card already carries a pulsing
|
|
1206
1206
|
accent dot, and a second accent bar under it shouts. */
|
|
1207
|
-
.session-item:not(.active) .progress-fill,
|
|
1208
1207
|
.wf-live-bar .progress-fill {
|
|
1209
1208
|
background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
|
|
1210
1209
|
}
|
|
@@ -1216,6 +1215,14 @@ body.booting .boot-splash {
|
|
|
1216
1215
|
font-variant-numeric: tabular-nums;
|
|
1217
1216
|
}
|
|
1218
1217
|
|
|
1218
|
+
.session-progress .progress-bar {
|
|
1219
|
+
display: flex;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.progress-fill.done {
|
|
1223
|
+
background: var(--success);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1219
1226
|
.context-bar-labels {
|
|
1220
1227
|
display: flex;
|
|
1221
1228
|
justify-content: space-between;
|
|
@@ -4489,6 +4496,75 @@ body.show-resizing iframe {
|
|
|
4489
4496
|
background: var(--border);
|
|
4490
4497
|
}
|
|
4491
4498
|
|
|
4499
|
+
.pane-group {
|
|
4500
|
+
position: relative;
|
|
4501
|
+
display: flex;
|
|
4502
|
+
gap: 2px;
|
|
4503
|
+
min-width: 0;
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
/* Over the tabs, so a tab's own background does not hide it. */
|
|
4507
|
+
.pane-group:not(.closed)::after {
|
|
4508
|
+
content: '';
|
|
4509
|
+
position: absolute;
|
|
4510
|
+
inset: auto 0 1px;
|
|
4511
|
+
z-index: 1;
|
|
4512
|
+
height: 2px;
|
|
4513
|
+
background: var(--g);
|
|
4514
|
+
pointer-events: none;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
.pane-group-chip,
|
|
4518
|
+
.pane-group-input {
|
|
4519
|
+
flex: none;
|
|
4520
|
+
align-self: center;
|
|
4521
|
+
height: 18px;
|
|
4522
|
+
margin: 0 2px;
|
|
4523
|
+
padding: 0 8px;
|
|
4524
|
+
border: 0;
|
|
4525
|
+
border-radius: 6px;
|
|
4526
|
+
font-family: var(--font-mono);
|
|
4527
|
+
font-size: 11px;
|
|
4528
|
+
font-weight: 600;
|
|
4529
|
+
letter-spacing: 0.3px;
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
.pane-group-chip {
|
|
4533
|
+
min-width: 14px;
|
|
4534
|
+
max-width: 140px;
|
|
4535
|
+
overflow: hidden;
|
|
4536
|
+
text-overflow: ellipsis;
|
|
4537
|
+
white-space: nowrap;
|
|
4538
|
+
background: var(--g);
|
|
4539
|
+
color: var(--bg-deep);
|
|
4540
|
+
cursor: pointer;
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
.pane-group-chip:hover {
|
|
4544
|
+
filter: brightness(1.12);
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
.pane-group-count {
|
|
4548
|
+
font-weight: 400;
|
|
4549
|
+
opacity: 0.75;
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
.pane-group-count:not(:first-child) {
|
|
4553
|
+
margin-left: 6px;
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
.pane-group-chip.drop-in {
|
|
4557
|
+
box-shadow: 0 0 0 2px var(--accent);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
.pane-group-input {
|
|
4561
|
+
width: 110px;
|
|
4562
|
+
color: var(--text-primary);
|
|
4563
|
+
background: var(--bg-deep);
|
|
4564
|
+
box-shadow: inset 0 0 0 1px var(--g);
|
|
4565
|
+
outline: none;
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4492
4568
|
.pane-strip.drop-hot {
|
|
4493
4569
|
background: var(--accent-dim);
|
|
4494
4570
|
}
|
|
@@ -7547,6 +7623,10 @@ body.light .msg-assistant .msg-text {
|
|
|
7547
7623
|
overflow-y: auto;
|
|
7548
7624
|
}
|
|
7549
7625
|
|
|
7626
|
+
.modal.msg-detail-dialog:focus {
|
|
7627
|
+
outline: none;
|
|
7628
|
+
}
|
|
7629
|
+
|
|
7550
7630
|
/* Before .modal.fullscreen on purpose: at equal specificity the later
|
|
7551
7631
|
fullscreen rule must win, same as for every other per-modal size. */
|
|
7552
7632
|
.modal.msg-detail-dialog {
|
|
@@ -10441,4 +10521,32 @@ pre.mermaid[data-mermaid-error]::before {
|
|
|
10441
10521
|
color: var(--text-primary);
|
|
10442
10522
|
font-weight: 600;
|
|
10443
10523
|
}
|
|
10524
|
+
.pane-menu-dot {
|
|
10525
|
+
flex: none;
|
|
10526
|
+
width: 8px;
|
|
10527
|
+
height: 8px;
|
|
10528
|
+
border-radius: 50%;
|
|
10529
|
+
background: var(--g);
|
|
10530
|
+
}
|
|
10531
|
+
.pane-menu-swatches {
|
|
10532
|
+
display: flex;
|
|
10533
|
+
gap: 4px;
|
|
10534
|
+
padding: 4px 8px;
|
|
10535
|
+
}
|
|
10536
|
+
.pane-menu-item.pane-menu-swatch {
|
|
10537
|
+
flex: none;
|
|
10538
|
+
width: 16px;
|
|
10539
|
+
height: 16px;
|
|
10540
|
+
padding: 0;
|
|
10541
|
+
border-radius: 50%;
|
|
10542
|
+
background: var(--g);
|
|
10543
|
+
}
|
|
10544
|
+
.pane-menu-item.pane-menu-swatch:hover,
|
|
10545
|
+
.pane-menu-item.pane-menu-swatch:focus-visible,
|
|
10546
|
+
.pane-menu-swatch.on {
|
|
10547
|
+
background: var(--g);
|
|
10548
|
+
box-shadow:
|
|
10549
|
+
0 0 0 2px var(--bg-elevated),
|
|
10550
|
+
0 0 0 3px var(--text-primary);
|
|
10551
|
+
}
|
|
10444
10552
|
/* #endregion */
|
package/server.js
CHANGED
|
@@ -48,7 +48,8 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
|
|
|
48
48
|
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
|
|
49
49
|
const { readTerminalConfig } = require('./lib/terminal');
|
|
50
50
|
const { createTerminalClient } = require('./lib/terminal-client');
|
|
51
|
-
const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
|
|
51
|
+
const { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer } = require('./lib/kanbot');
|
|
52
|
+
const { AGENT_ID_RE } = require('./cli');
|
|
52
53
|
const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
|
|
53
54
|
const { readLiveSessions, isPidAlive, noteReplacedSessions } = require('./lib/live-sessions');
|
|
54
55
|
const { createProcStats } = require('./lib/proc-stats');
|
|
@@ -3526,6 +3527,7 @@ async function ensureKanbot() {
|
|
|
3526
3527
|
const started = await terminal.startNew(kanbot.startSpec({
|
|
3527
3528
|
model: kanbotModelSetting(),
|
|
3528
3529
|
boardUrl: `http://127.0.0.1:${boardPort}`,
|
|
3530
|
+
costCli: findCostCli({ claudeDir: CLAUDE_DIR, which: whichSync }),
|
|
3529
3531
|
}));
|
|
3530
3532
|
if (started.error) return started;
|
|
3531
3533
|
kanbot.ptyId = started.id;
|
|
@@ -3799,7 +3801,7 @@ async function confirmKind(absPath, kind) {
|
|
|
3799
3801
|
}
|
|
3800
3802
|
|
|
3801
3803
|
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
3802
|
-
const PREVIEW_MAX_BYTES =
|
|
3804
|
+
const PREVIEW_MAX_BYTES = 16 * 1024 * 1024;
|
|
3803
3805
|
// Source in a modal is for reading, not for scrolling a generated bundle.
|
|
3804
3806
|
const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
|
|
3805
3807
|
|
|
@@ -3989,6 +3991,19 @@ app.post('/api/session/open', async (req, res) => {
|
|
|
3989
3991
|
}
|
|
3990
3992
|
});
|
|
3991
3993
|
|
|
3994
|
+
app.post('/api/session/cost', (req, res) => {
|
|
3995
|
+
const { id, agent } = req.body || {};
|
|
3996
|
+
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
|
|
3997
|
+
if (agent !== undefined && (typeof agent !== 'string' || !AGENT_ID_RE.test(agent))) {
|
|
3998
|
+
return res.status(400).json({ error: 'agent must be an agent-… id' });
|
|
3999
|
+
}
|
|
4000
|
+
broadcast({ type: 'session:cost', id, ...(agent && { agent }) });
|
|
4001
|
+
const query = new URLSearchParams({ view: 'detail', session: id, ...(agent && { agent }) });
|
|
4002
|
+
const costUrl = COST_URL || readCostServer(CLAUDE_DIR)?.url;
|
|
4003
|
+
// A page opens the Cost tab only under the hub.
|
|
4004
|
+
res.json({ delivered: process.env.CLAUDE_HUB ? clients.size : 0, url: costUrl ? `${new URL(`?${query}`, costUrl)}` : null });
|
|
4005
|
+
});
|
|
4006
|
+
|
|
3992
4007
|
// #region SESSION_PINS
|
|
3993
4008
|
function broadcastPins(changed) {
|
|
3994
4009
|
for (const { id, state } of changed) broadcast({ type: 'session:pin', id, state });
|