claude-code-kanban 7.10.0 → 7.12.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/lib/agent-log.js +17 -0
- package/lib/show.js +6 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +9 -6
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +8 -3
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +3 -1
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +67 -0
- package/plugin/plugins/claude-code-kanban/skills/visualize/template.html +215 -0
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +25 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +7 -0
- package/public/app.js +102 -36
- package/public/style.css +1 -0
- package/server.js +2 -7
package/lib/agent-log.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A message to a stopped subagent spawns it again under the same id, and the plugin appends
|
|
4
|
+
// another start line. Each start line begins a new run, so nothing of the run before it shows.
|
|
5
|
+
function foldAgentLines(raw) {
|
|
6
|
+
let merged = {};
|
|
7
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
8
|
+
if (!line.trim()) continue;
|
|
9
|
+
let obj;
|
|
10
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
11
|
+
if (obj.event === 'start') merged = {};
|
|
12
|
+
Object.assign(merged, obj);
|
|
13
|
+
}
|
|
14
|
+
return merged;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { foldAgentLines };
|
package/lib/show.js
CHANGED
|
@@ -6,6 +6,7 @@ const crypto = require('node:crypto');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const express = require('express');
|
|
9
|
+
const { startFolder } = require('./folder-dialog');
|
|
9
10
|
const { httpError } = require('./http-error');
|
|
10
11
|
|
|
11
12
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -24,14 +25,15 @@ function isLabel(v) {
|
|
|
24
25
|
// A string is a reason the body is refused.
|
|
25
26
|
function parsePost(body) {
|
|
26
27
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
|
|
27
|
-
const { sessionId, title, key, kind = 'markdown', content, file } = body;
|
|
28
|
+
const { sessionId, title, key, kind = 'markdown', content, file, base } = body;
|
|
28
29
|
if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
|
|
29
30
|
if (!isLabel(title)) return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
30
31
|
if (key != null && !isLabel(key)) return `key must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
31
32
|
if (!Object.hasOwn(EXT, kind)) return 'kind must be markdown or html';
|
|
32
33
|
if (content != null) return 'content is not accepted: call show with only title and key to get a file to write the card into';
|
|
33
34
|
if (file != null && (typeof file !== 'string' || !path.isAbsolute(file))) return 'file must be an absolute path';
|
|
34
|
-
|
|
35
|
+
if (base != null && !startFolder(base)) return 'base must be an absolute path to an existing folder';
|
|
36
|
+
return { sessionId, title, key: key ?? null, kind, file: file ?? null, base: base ?? null };
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function writeAtomic(file, text) {
|
|
@@ -193,7 +195,7 @@ function createShowStore(o) {
|
|
|
193
195
|
// `kind` of a file post is the one readPreviewFile gave when it was posted. A post without a file
|
|
194
196
|
// is a claim: cck names the file and the session writes it. cck does not create it, because
|
|
195
197
|
// Claude Code's Write refuses to overwrite a file the session has not read.
|
|
196
|
-
function post(terminalId, { sessionId, title, key, kind, file }) {
|
|
198
|
+
function post(terminalId, { sessionId, title, key, kind, file, base }) {
|
|
197
199
|
const dir = folder(sessionId);
|
|
198
200
|
if (!dir) throw httpError(409, 'no transcript found for that session');
|
|
199
201
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -210,7 +212,7 @@ function createShowStore(o) {
|
|
|
210
212
|
seen.delete(id);
|
|
211
213
|
}
|
|
212
214
|
if (!old) at = posts.length;
|
|
213
|
-
const entry = { id, title, key, kind, file, updatedAt: new Date(now()).toISOString() };
|
|
215
|
+
const entry = { id, title, key, kind, file, base, updatedAt: new Date(now()).toISOString() };
|
|
214
216
|
posts[at] = entry;
|
|
215
217
|
writeIndex(dir, sessionId, posts);
|
|
216
218
|
bind(terminalId, sessionId);
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.8",
|
|
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineInterface, Register, ToolCallArgs } from 'claude-code'
|
|
1
|
+
import type { AgentSpawnInput, EngineInterface, Register, ToolCallArgs } from 'claude-code'
|
|
2
2
|
import { cckRoot } from './context'
|
|
3
3
|
|
|
4
4
|
// Mods cannot delete files. The server reads any status but "waiting" as no ask.
|
|
@@ -148,21 +148,24 @@ async function markStopped($: EngineInterface) {
|
|
|
148
148
|
await $.fs.write(`${await sessionDir($)}/_stop.json`, '')
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
// The name map lets TeammateIdle, which names the teammate, find its agent id.
|
|
152
|
-
|
|
151
|
+
// The name map lets TeammateIdle, which names the teammate, find its agent id. The prompt is on
|
|
152
|
+
// the start line because a resume runs a new prompt, and the server's own lookup finds the first.
|
|
153
|
+
async function agentStarted($: EngineInterface, e: AgentSpawnInput, agentId: string, model: string) {
|
|
153
154
|
const dir = await sessionDir($)
|
|
154
155
|
const ts = new Date().toISOString()
|
|
155
156
|
await Promise.all([
|
|
156
157
|
appendLine($, `${dir}/${agentId}.jsonl`, {
|
|
157
158
|
agentId,
|
|
158
|
-
type,
|
|
159
|
+
type: e.subagentType,
|
|
159
160
|
model,
|
|
160
161
|
event: 'start',
|
|
161
162
|
status: 'active',
|
|
163
|
+
...(e.prompt && { prompt: e.prompt }),
|
|
164
|
+
...(e.description && { description: e.description }),
|
|
162
165
|
startedAt: ts,
|
|
163
166
|
updatedAt: ts,
|
|
164
167
|
}),
|
|
165
|
-
$.fs.write(`${dir}/_name-${name ??
|
|
168
|
+
$.fs.write(`${dir}/_name-${e.name ?? e.subagentType}.id`, agentId),
|
|
166
169
|
])
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -249,7 +252,7 @@ export const register: Register = on => {
|
|
|
249
252
|
})
|
|
250
253
|
on('agent.spawn', async ($, e, next) => {
|
|
251
254
|
const result = await next(e)
|
|
252
|
-
if (result.agentId) await agentStarted($, result.agentId,
|
|
255
|
+
if (result.agentId) await agentStarted($, e, result.agentId, result.model)
|
|
253
256
|
return result
|
|
254
257
|
})
|
|
255
258
|
// An interrupted turn fires no Stop hook, so it does not mark the session unread either.
|
|
@@ -4,7 +4,7 @@ import { cckRoot } from './context'
|
|
|
4
4
|
export const TOOL = 'mcp__claude-code-kanban__show'
|
|
5
5
|
export const SKILL_LINE = /^\s*-\s*claude-code-kanban:show(?=:|\s*$)/
|
|
6
6
|
|
|
7
|
-
type ShowInput = { title: string; key?: string; kind?: 'markdown' | 'html'; file?: string }
|
|
7
|
+
type ShowInput = { title: string; key?: string; kind?: 'markdown' | 'html'; file?: string; base?: string }
|
|
8
8
|
type Shown = { title: string; key?: string | null; index: number; count: number; path?: string | null }
|
|
9
9
|
|
|
10
10
|
export function boardPort(cckUrl: string) {
|
|
@@ -67,8 +67,8 @@ async function post($: EngineInterface, input: ShowInput) {
|
|
|
67
67
|
if (!t) return { deny: 'The overlay needs a Claude Code session started in the cck terminal.' }
|
|
68
68
|
const token = await terminalToken($, t.url)
|
|
69
69
|
if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
|
|
70
|
-
const { title, key, kind, file } = input
|
|
71
|
-
const body = JSON.stringify({ sessionId: await $.session.id(), title, key, kind, file })
|
|
70
|
+
const { title, key, kind, file, base } = input
|
|
71
|
+
const body = JSON.stringify({ sessionId: await $.session.id(), title, key, kind, file, base })
|
|
72
72
|
let res: Awaited<ReturnType<EngineInterface['http']['fetch']>>
|
|
73
73
|
try {
|
|
74
74
|
res = await $.http.fetch(`${t.url}/api/terminals/${encodeURIComponent(t.terminalId)}/show`, {
|
|
@@ -103,6 +103,11 @@ export const register: Register = on => {
|
|
|
103
103
|
},
|
|
104
104
|
file: { type: 'string', description: 'Absolute path of an existing markdown, HTML, image or text file' },
|
|
105
105
|
key: { type: 'string', description: 'Card name: the same key replaces the card in place and keeps its file' },
|
|
106
|
+
base: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description:
|
|
109
|
+
"Absolute path of the folder that the card's path:line links resolve against, usually the repo root. Always pass it with a call stack. Without it, links resolve against the session cwd, which is wrong for a worktree or another repo",
|
|
110
|
+
},
|
|
106
111
|
},
|
|
107
112
|
required: ['title'],
|
|
108
113
|
},
|
|
@@ -38,7 +38,9 @@ The board draws the tree lines, links, focus and folds from this notation:
|
|
|
38
38
|
- The fence tag is `callstack`.
|
|
39
39
|
- Caller on top. Indent each callee 2 spaces under its caller; the board turns the spaces into tree lines. Several roots are fine.
|
|
40
40
|
- One frame per line: the function or method name as one identifier, spelled as in the code.
|
|
41
|
-
- The note goes after 2 or more spaces: a `path:line` relative to the
|
|
41
|
+
- The note goes after 2 or more spaces: a `path:line` relative to the card's `base`, which the board links, or a few words. Line the notes up in one column.
|
|
42
|
+
- Post every card with a call stack with `base`: the absolute path of the repo root that holds the files. Without it, links resolve against the session cwd, which is a different checkout when you work in a worktree or another repo.
|
|
43
|
+
- Write the full relative path on each linked frame. A bare file name (`place.ts:40`) or a bare line (`:40`) never resolves.
|
|
42
44
|
- A `*` after the name, with one space, marks the frame to look at. One per stack.
|
|
43
45
|
- `+` or `-` in the first column marks a frame the change adds or removes. In a stack with any `+` or `-`, every other line starts with one space.
|
|
44
46
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: visualize
|
|
3
|
+
description: Present a topic as one self-contained HTML page — findings, options and a pick, a system overview. Use when the user asks to visualize something, or when an answer outgrows chat (options plus a pick, a diagram plus a table).
|
|
4
|
+
argument-hint: '[what to present]'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Visualize
|
|
8
|
+
|
|
9
|
+
Present this: $ARGUMENTS. With no argument, present the subject of this conversation.
|
|
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 (on a page with no options, its main path), and an **exhibit** is the one table, figure or block that proves a section's claim.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
1. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`; the `<style>` block stays byte for byte, and your own CSS goes in a second `<style>` after it.
|
|
16
|
+
2. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point:
|
|
17
|
+
1. `.kicker`, then `<title>` and `<h1>`: a name of 2 to 5 words. `.sub`: the question, in one line.
|
|
18
|
+
2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
|
|
19
|
+
3. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and at most 2 short paragraphs under it.
|
|
20
|
+
4. Close with what to do next and `.sources`.
|
|
21
|
+
3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit can be interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
|
|
22
|
+
4. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
|
|
23
|
+
5. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
|
|
24
|
+
|
|
25
|
+
## Principles
|
|
26
|
+
|
|
27
|
+
Every exhibit, ready or new, keeps these:
|
|
28
|
+
|
|
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.
|
|
30
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
## Interactive exhibits
|
|
37
|
+
|
|
38
|
+
- An exhibit is the figure, its controls and the outputs they drive. The script draws the figure and writes only inside its exhibit; the text outside it is static HTML.
|
|
39
|
+
- Keep it small to edit: all inputs in one state object, one draw function that builds every figure from that state, and controls that only change state and call draw. Keep the computation apart from the drawing.
|
|
40
|
+
- Declare controls and presets in the markup, next to the text that names each scenario, and read them in one place. A new scenario is then one line of HTML.
|
|
41
|
+
- Colors come from the tokens on every draw, in canvas too, so a theme change needs no extra code.
|
|
42
|
+
- Nothing runs while the reader does nothing, except a running model that is on screen.
|
|
43
|
+
- Numbers from a model are marked as model numbers.
|
|
44
|
+
- The script goes at the end of `<body>`; its CSS goes in the second `<style>`.
|
|
45
|
+
|
|
46
|
+
## Blocks
|
|
47
|
+
|
|
48
|
+
Ready parts in the template's CSS; its body has one of each.
|
|
49
|
+
|
|
50
|
+
| Block | Markup | Good for |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Kicker | `p.kicker` | Ticket or kind of page |
|
|
53
|
+
| Lead | `p.lead` | The answer; `<b>` takes the accent |
|
|
54
|
+
| Lineup | `.lineup > div > b + span + small` | Every option as a tile: index, name, 2-word verdict |
|
|
55
|
+
| Stat tiles | `.kpis > .kpi > span + b + small` | 1 to 4 headline numbers |
|
|
56
|
+
| Section | `section > p.eyebrow + h2` | CSS numbers the eyebrow |
|
|
57
|
+
| Cards | `.cards > .card > h3 + p.verdict + p + pre + ul` | Trade-offs; `li.pro`, `li.con` |
|
|
58
|
+
| Table | `.table > table`; `td.num`, `td.c`, `tr[data-tone]` | A matrix |
|
|
59
|
+
| Heatmap | `td.yes`, `td.part`, `td.no` | Tinted cells: `yes` the good outcome, `no` the bad, whatever word the cell holds |
|
|
60
|
+
| Figure | `figure > svg + figcaption` | A diagram or a chart |
|
|
61
|
+
| Points | `ul.points > li > b + text` | Findings, each led by its claim |
|
|
62
|
+
| Callout | `div.callout` | A warning |
|
|
63
|
+
| Steps | `ol.steps` | What to do next |
|
|
64
|
+
| Tag, dot, legend | `span.tag`, `span.dot`, `.legend > span > i` | Labels and keys |
|
|
65
|
+
| Sources | `div.sources` | Where each claim comes from |
|
|
66
|
+
|
|
67
|
+
`data-tone` takes `accent`, `info`, `success`, `warning`, `danger` or `muted` on any block, legend keys included.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Page Title</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Defaults in :where() have zero specificity, so a host that sets the same tokens on :root wins. */
|
|
9
|
+
:where(:root) {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--color-bg: #f7f5f2; --color-bg-subtle: #ece9e4;
|
|
12
|
+
--color-text: #16130f; --color-text-muted: #5f5a53; --color-border: #d6d1c9;
|
|
13
|
+
--color-accent: #c4581c;
|
|
14
|
+
--color-info: #2563eb; --color-success: #1a8a5a; --color-warning: #b07d0a; --color-danger: #c0392b;
|
|
15
|
+
--color-series-1: #2a78d6; --color-series-2: #c4511c; --color-series-3: #008a72; --color-series-4: #ad6f00;
|
|
16
|
+
--color-series-5: #c4467a; --color-series-6: #008300; --color-series-7: #4a3aa7; --color-series-8: #d03a3a;
|
|
17
|
+
--color-ramp-1: #5598e7; --color-ramp-2: #2a78d6; --color-ramp-3: #1c5cab; --color-ramp-4: #104281; --color-ramp-5: #0a2b57;
|
|
18
|
+
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
19
|
+
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
|
|
20
|
+
--font-serif: "Playfair Display", Georgia, "Times New Roman", serif;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:where(:root) {
|
|
24
|
+
--color-bg: #16181c; --color-bg-subtle: #101114;
|
|
25
|
+
--color-text: #f0f1f3; --color-text-muted: #9a9da5; --color-border: #363840;
|
|
26
|
+
--color-accent: #e86f33;
|
|
27
|
+
--color-info: #60a5fa; --color-success: #3ecf8e; --color-warning: #f0b429; --color-danger: #ef5350;
|
|
28
|
+
--color-series-1: #3987e5; --color-series-2: #d95926; --color-series-3: #199e70; --color-series-4: #c98500;
|
|
29
|
+
--color-series-5: #d55181; --color-series-6: #2a9a2a; --color-series-7: #9085e9; --color-series-8: #e66767;
|
|
30
|
+
--color-ramp-1: #256abf; --color-ramp-2: #3987e5; --color-ramp-3: #6da7ec; --color-ramp-4: #9ec5f4; --color-ramp-5: #cde2fb;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
:root { --surface: color-mix(in srgb, var(--color-text) 4%, var(--color-bg)); }
|
|
34
|
+
:where(.dot, .tag) { --tone: var(--color-text-muted); }
|
|
35
|
+
:where(.callout) { --tone: var(--color-accent); }
|
|
36
|
+
[data-tone="accent"] { --tone: var(--color-accent); }
|
|
37
|
+
[data-tone="info"] { --tone: var(--color-info); }
|
|
38
|
+
[data-tone="success"] { --tone: var(--color-success); }
|
|
39
|
+
[data-tone="warning"] { --tone: var(--color-warning); }
|
|
40
|
+
[data-tone="danger"] { --tone: var(--color-danger); }
|
|
41
|
+
[data-tone="muted"] { --tone: var(--color-text-muted); }
|
|
42
|
+
|
|
43
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
44
|
+
body { margin: 0; background: var(--color-bg); color: var(--color-text); font: 16px/1.65 var(--font-sans); -webkit-text-size-adjust: 100%; }
|
|
45
|
+
main { max-width: 980px; margin: 0 auto; padding: 64px 16px 96px; counter-reset: section; }
|
|
46
|
+
p, li { max-width: 68ch; }
|
|
47
|
+
p { margin: 0 0 14px; }
|
|
48
|
+
ul, ol { margin: 0 0 16px; padding-left: 22px; }
|
|
49
|
+
li { margin: 4px 0; }
|
|
50
|
+
a { color: var(--color-accent); text-underline-offset: 3px; }
|
|
51
|
+
b, strong { font-weight: 600; }
|
|
52
|
+
code { font: .85em var(--font-mono); background: var(--surface); border: 1px solid var(--color-border); border-radius: 5px; padding: 1px 5px; }
|
|
53
|
+
pre { font: 13px/1.6 var(--font-mono); background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 10px; padding: 12px 14px; margin: 0 0 16px; overflow-x: auto; }
|
|
54
|
+
pre code { font-size: inherit; background: none; border: 0; padding: 0; }
|
|
55
|
+
.muted { color: var(--color-text-muted); }
|
|
56
|
+
|
|
57
|
+
.kicker { font: 500 12px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--color-accent); margin: 0 0 18px; }
|
|
58
|
+
h1 { font: 400 48px/1.08 var(--font-serif); letter-spacing: -.01em; margin: 0 0 16px; }
|
|
59
|
+
.sub { font-size: 17px; color: var(--color-text-muted); max-width: 46em; margin: 0 0 28px; }
|
|
60
|
+
.lead { font-size: 21px; line-height: 1.5; max-width: 40em; margin: 0 0 32px; }
|
|
61
|
+
.lead b { color: var(--color-accent); }
|
|
62
|
+
|
|
63
|
+
.lineup { display: grid; grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); gap: 8px; margin: 0 0 40px; }
|
|
64
|
+
.lineup > div { border: 1px solid var(--color-border); border-radius: 12px; padding: 12px 12px 10px; background: var(--surface); }
|
|
65
|
+
.lineup b { display: block; font: 400 28px/1 var(--font-serif); color: var(--color-text-muted); }
|
|
66
|
+
.lineup span { display: block; font-size: 13px; font-weight: 600; line-height: 1.3; margin-top: 8px; }
|
|
67
|
+
.lineup small { display: block; font: 11px/1.35 var(--font-mono); color: var(--color-text-muted); margin-top: 4px; }
|
|
68
|
+
.lineup > [data-tone] { border-color: var(--tone); background: color-mix(in srgb, var(--tone) 12%, var(--color-bg)); box-shadow: 0 10px 32px -14px var(--tone); }
|
|
69
|
+
.lineup > [data-tone] b { color: var(--tone); }
|
|
70
|
+
|
|
71
|
+
section { counter-increment: section; margin-top: 88px; }
|
|
72
|
+
.eyebrow { max-width: none; display: flex; align-items: center; gap: 12px; font: 500 12px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--color-text-muted); margin: 0 0 14px; }
|
|
73
|
+
.eyebrow::before { content: counter(section, decimal-leading-zero); color: var(--color-accent); }
|
|
74
|
+
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--color-border); }
|
|
75
|
+
h2 { font: 400 32px/1.2 var(--font-serif); letter-spacing: -.005em; max-width: 30em; margin: 0 0 22px; }
|
|
76
|
+
h3 { font-size: 17px; line-height: 1.4; font-weight: 600; margin: 36px 0 10px; }
|
|
77
|
+
|
|
78
|
+
.callout { border: 1px solid var(--color-border); border-radius: 12px; background: var(--surface); box-shadow: inset 3px 0 0 var(--tone); padding: 16px 20px; margin: 0 0 20px; }
|
|
79
|
+
.callout p { max-width: none; }
|
|
80
|
+
.callout > :last-child { margin-bottom: 0; }
|
|
81
|
+
|
|
82
|
+
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 0 0 16px; }
|
|
83
|
+
.kpi { border: 1px solid var(--color-border); border-radius: 12px; background: var(--surface); padding: 14px 16px; }
|
|
84
|
+
.kpi > span { display: block; font: 500 11px/1.4 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); }
|
|
85
|
+
.kpi > b { display: block; font: 400 40px/1.1 var(--font-serif); font-variant-numeric: lining-nums tabular-nums; margin: 8px 0 4px; color: var(--tone, var(--color-text)); }
|
|
86
|
+
.kpi > small { display: block; font-size: 13px; color: var(--color-text-muted); }
|
|
87
|
+
|
|
88
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; margin: 0 0 16px; }
|
|
89
|
+
.card { border: 1px solid var(--color-border); border-radius: 14px; padding: 20px 22px; background: var(--surface); }
|
|
90
|
+
.card[data-tone] { border-color: var(--tone); background: color-mix(in srgb, var(--tone) 7%, var(--color-bg)); box-shadow: 0 16px 40px -24px var(--tone); }
|
|
91
|
+
.card h3 { font-size: 14px; margin: 0 0 10px; color: var(--color-text-muted); }
|
|
92
|
+
.card .verdict { font: 400 20px/1.35 var(--font-serif); color: var(--color-text); margin: 0 0 12px; }
|
|
93
|
+
.card > p:not(.verdict), .card ul { font-size: 13px; line-height: 1.55; color: var(--color-text-muted); }
|
|
94
|
+
.card > p:not(.verdict) { margin: 0 0 12px; }
|
|
95
|
+
.card pre { font-size: 12px; padding: 8px 10px; margin: 0 0 12px; white-space: pre-wrap; }
|
|
96
|
+
.card ul { margin: 0; padding-left: 18px; }
|
|
97
|
+
.card li { margin: 3px 0; }
|
|
98
|
+
li.pro::marker { content: "+ "; color: var(--color-success); font-weight: 700; }
|
|
99
|
+
li.con::marker { content: "− "; color: var(--color-danger); font-weight: 700; }
|
|
100
|
+
|
|
101
|
+
.tag { display: inline-block; font: 500 10.5px/18px var(--font-mono); letter-spacing: .06em; text-transform: uppercase; padding: 0 7px; border-radius: 9px; margin-left: 8px; vertical-align: 1px; color: var(--tone); background: color-mix(in srgb, var(--tone) 15%, transparent); }
|
|
102
|
+
.dot { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; margin-right: 8px; vertical-align: 1px; font: 600 11px var(--font-mono); color: var(--color-bg); background: var(--tone); }
|
|
103
|
+
|
|
104
|
+
.table { overflow-x: auto; margin: 0 0 20px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--surface); }
|
|
105
|
+
table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
|
106
|
+
th, td { padding: 10px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--color-border); }
|
|
107
|
+
tbody tr:last-child > td { border-bottom: 0; }
|
|
108
|
+
th { font: 500 11px/1.4 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--color-text-muted); background: var(--color-bg-subtle); }
|
|
109
|
+
td:first-child { white-space: nowrap; font-weight: 500; }
|
|
110
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
111
|
+
td.c { text-align: center; white-space: nowrap; }
|
|
112
|
+
td.yes { --tone: var(--color-success); } td.no { --tone: var(--color-danger); } td.part { --tone: var(--color-warning); }
|
|
113
|
+
td.yes, td.no, td.part { color: var(--tone); font-weight: 600; background: color-mix(in srgb, var(--tone) 13%, transparent); }
|
|
114
|
+
td.yes::before { content: "● "; } td.part::before { content: "◐ "; } td.no::before { content: "○ "; }
|
|
115
|
+
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
|
+
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; }
|
|
118
|
+
figure svg { display: block; width: 100%; height: auto; }
|
|
119
|
+
figcaption { font-size: 13px; color: var(--color-text-muted); margin-top: 14px; }
|
|
120
|
+
@media (max-width: 600px) { figure.wide { overflow-x: auto; } figure.wide svg { min-width: 680px; } }
|
|
121
|
+
|
|
122
|
+
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font: 12px var(--font-mono); color: var(--color-text-muted); margin: 0 0 14px; }
|
|
123
|
+
.legend i { display: inline-block; width: 14px; height: 10px; border-radius: 2px; margin-right: 6px; background: var(--tone, var(--color-text-muted)); }
|
|
124
|
+
|
|
125
|
+
ul.points { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 8px 36px; }
|
|
126
|
+
ul.points > li { max-width: none; margin: 0; padding: 14px 0 0; border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: 15px; }
|
|
127
|
+
ul.points > li > b:first-child { display: block; font: 400 19px/1.35 var(--font-serif); color: var(--color-text); margin-bottom: 6px; }
|
|
128
|
+
|
|
129
|
+
ol.steps { list-style: none; counter-reset: step; padding: 0; }
|
|
130
|
+
ol.steps > li { counter-increment: step; position: relative; padding: 2px 0 20px 48px; }
|
|
131
|
+
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
|
+
ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 36px; bottom: 4px; width: 1px; background: var(--color-border); }
|
|
133
|
+
|
|
134
|
+
.sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 12px/1.7 var(--font-mono); color: var(--color-text-muted); }
|
|
135
|
+
.sources p { max-width: none; }
|
|
136
|
+
.sources code { font-size: inherit; background: none; border: 0; padding: 0; }
|
|
137
|
+
|
|
138
|
+
@media (max-width: 600px) {
|
|
139
|
+
main { padding-top: 36px; }
|
|
140
|
+
h1 { font-size: 34px; }
|
|
141
|
+
.lead { font-size: 18px; }
|
|
142
|
+
h2 { font-size: 25px; }
|
|
143
|
+
section { margin-top: 64px; }
|
|
144
|
+
figure { padding: 14px; }
|
|
145
|
+
}
|
|
146
|
+
@media print { main { max-width: none; padding: 0; } figure, .card, .kpi, tr { break-inside: avoid; } .card, .lineup > div { box-shadow: none; } }
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<main>
|
|
151
|
+
<p class="kicker">Ticket or context · kind of page</p>
|
|
152
|
+
<h1>Page Title</h1>
|
|
153
|
+
<p class="sub">The question this page answers.</p>
|
|
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>
|
|
166
|
+
|
|
167
|
+
<section>
|
|
168
|
+
<p class="eyebrow">Overview</p>
|
|
169
|
+
<h2>Each heading is a claim that can be true or false</h2>
|
|
170
|
+
<figure class="wide">
|
|
171
|
+
<svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the diagram shows</title></svg>
|
|
172
|
+
<figcaption>One sentence: what to notice.</figcaption>
|
|
173
|
+
</figure>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
<section>
|
|
177
|
+
<p class="eyebrow">Options</p>
|
|
178
|
+
<h2>Cards lead with the verdict</h2>
|
|
179
|
+
<div class="cards">
|
|
180
|
+
<div class="card" data-tone="accent">
|
|
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>
|
|
213
|
+
</main>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|
|
@@ -272,6 +272,31 @@ test('a subagent stop with an empty answer leaves lastMessage out', async ($, on
|
|
|
272
272
|
expect('lastMessage' in stop).toBe(false)
|
|
273
273
|
})
|
|
274
274
|
|
|
275
|
+
test('a resumed subagent appends a start line with the new task', async ($, on) => {
|
|
276
|
+
const files: Record<string, string> = {}
|
|
277
|
+
engine(on, files)
|
|
278
|
+
|
|
279
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
280
|
+
await $.turn.complete(turn({ agentId: 'a1', answer: 'first answer' }))
|
|
281
|
+
await $.agent.spawn({ ...spawn('general-purpose'), prompt: 'second task', description: '' })
|
|
282
|
+
|
|
283
|
+
const all = lines(files[`${SESSION}/a1.jsonl`])
|
|
284
|
+
expect(all.map((l) => l.event)).toEqual(['start', 'stop', 'start'])
|
|
285
|
+
expect(all[2]).toMatchObject({ status: 'active', prompt: 'second task' })
|
|
286
|
+
expect('description' in all[2]).toBe(false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a first spawn records its prompt and description', async ($, on) => {
|
|
290
|
+
const files: Record<string, string> = {}
|
|
291
|
+
engine(on, files)
|
|
292
|
+
|
|
293
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
294
|
+
|
|
295
|
+
const [start] = lines(files[`${SESSION}/a1.jsonl`])
|
|
296
|
+
expect(start).toMatchObject({ prompt: 'p', description: 'd' })
|
|
297
|
+
expect('lastMessage' in start).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
275
300
|
test('a finished loop that was never spawned writes nothing', async ($, on) => {
|
|
276
301
|
const { writes } = engine(on, {})
|
|
277
302
|
|
|
@@ -102,6 +102,13 @@ test('posts the body with the terminal token and answers with the position', asy
|
|
|
102
102
|
])
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test('sends the base for the card links', async ($, on) => {
|
|
106
|
+
const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Stack', key: 'stack', index: 1, count: 1, path: 'C:/s/p1.md' } })
|
|
107
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
108
|
+
await $.tool.call(call({ title: 'Stack', key: 'stack', base: 'C:/proj-worktree' }))
|
|
109
|
+
expect(board.requests[0]?.body).toEqual({ sessionId: 'sid-1', title: 'Stack', key: 'stack', base: 'C:/proj-worktree' })
|
|
110
|
+
})
|
|
111
|
+
|
|
105
112
|
test('a claim sends no file and answers with the file to write', async ($, on) => {
|
|
106
113
|
const path = 'C:/s/.cck/show/p1.md'
|
|
107
114
|
const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Plan', key: 'plan', index: 1, count: 1, path } })
|
package/public/app.js
CHANGED
|
@@ -8570,6 +8570,8 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
8570
8570
|
marks.delete(e.data.id);
|
|
8571
8571
|
} else if (type === 'reveal' && range) {
|
|
8572
8572
|
range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
8573
|
+
} else if (type === 'scrollTo') {
|
|
8574
|
+
requestAnimationFrame(() => scrollTo(e.data.x, e.data.y));
|
|
8573
8575
|
}
|
|
8574
8576
|
});
|
|
8575
8577
|
}
|
|
@@ -8583,6 +8585,23 @@ const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${revi
|
|
|
8583
8585
|
// not drive the board.
|
|
8584
8586
|
const bridgedFrames = new WeakSet();
|
|
8585
8587
|
const paneFrameScroll = new WeakMap();
|
|
8588
|
+
const hiddenPaneFrames = new WeakSet();
|
|
8589
|
+
|
|
8590
|
+
// Chrome resets a frame's scroll to 0 while the frame is display:none and does not give it back.
|
|
8591
|
+
function restorePaneFrameScroll(view, shown) {
|
|
8592
|
+
const frame = view.querySelector(':scope > iframe[srcdoc]');
|
|
8593
|
+
if (!frame) return;
|
|
8594
|
+
if (!shown) {
|
|
8595
|
+
hiddenPaneFrames.add(frame);
|
|
8596
|
+
} else if (hiddenPaneFrames.delete(frame)) {
|
|
8597
|
+
const s = paneFrameScroll.get(frame);
|
|
8598
|
+
if (s) postPaneScroll(frame, s);
|
|
8599
|
+
}
|
|
8600
|
+
}
|
|
8601
|
+
|
|
8602
|
+
function postPaneScroll(frame, { x, y }) {
|
|
8603
|
+
frame.contentWindow?.postMessage({ type: `${REVIEW_MSG}scrollTo`, x: Number(x) || 0, y: Number(y) || 0 }, '*');
|
|
8604
|
+
}
|
|
8586
8605
|
|
|
8587
8606
|
function bridgedFrameEls() {
|
|
8588
8607
|
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
@@ -9100,7 +9119,7 @@ function renderHtmlPreview(bodyEl, content) {
|
|
|
9100
9119
|
bodyEl.innerHTML = '';
|
|
9101
9120
|
// After the document's end the parser still adds the script to <body>, and it runs after
|
|
9102
9121
|
// the page's own scripts.
|
|
9103
|
-
bodyEl.appendChild(createPreviewFrame('preview-html-frame', content
|
|
9122
|
+
bodyEl.appendChild(createPreviewFrame('preview-html-frame themed-frame', themedSrcdoc(content)));
|
|
9104
9123
|
}
|
|
9105
9124
|
|
|
9106
9125
|
function isAbsolutePath(p) {
|
|
@@ -13409,7 +13428,8 @@ function syncPanes() {
|
|
|
13409
13428
|
const boardSide = split ? splitSideOf(split, 'board') : null;
|
|
13410
13429
|
sessionView.classList.toggle('pane-mode', split ? !boardSide : !!pane);
|
|
13411
13430
|
sessionView.classList.toggle('split-mode', !!split);
|
|
13412
|
-
|
|
13431
|
+
const term = wantsTerminal();
|
|
13432
|
+
if (!layout || term) closePanePop();
|
|
13413
13433
|
// key → side ('' unsplit). The focused side mounts last, so the frame limit evicts it last.
|
|
13414
13434
|
const sides = new Map();
|
|
13415
13435
|
if (split) {
|
|
@@ -13427,14 +13447,15 @@ function syncPanes() {
|
|
|
13427
13447
|
el.classList.toggle('on', side !== undefined);
|
|
13428
13448
|
markPaneSide(el, side, el.dataset.key === focusKey);
|
|
13429
13449
|
if (paneLineWanted.size && side !== undefined) applyPaneLine(el);
|
|
13450
|
+
restorePaneFrameScroll(el, side !== undefined && !term);
|
|
13430
13451
|
}
|
|
13431
13452
|
const boardWasSplit = boardInSplit();
|
|
13432
13453
|
markPaneSide(paneBoard, boardSide, !!boardSide && !pane);
|
|
13433
13454
|
if (boardWasSplit !== !!boardSide) applyAgentFooterCollapsed();
|
|
13434
|
-
placeSessionLog(
|
|
13455
|
+
placeSessionLog(term ? null : boardSide);
|
|
13435
13456
|
// Off screen, a framed page keeps focus and takes keys the user aims at the board.
|
|
13436
13457
|
const focused = document.activeElement;
|
|
13437
|
-
if (paneViews.contains(focused) && (
|
|
13458
|
+
if (paneViews.contains(focused) && (term || !focused.closest('.pane-view.on'))) focused.blur();
|
|
13438
13459
|
syncPaneReview();
|
|
13439
13460
|
if (!layout) return;
|
|
13440
13461
|
for (const b of document.querySelectorAll('.pane-tool')) b.disabled = !pane;
|
|
@@ -13611,19 +13632,16 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13611
13632
|
}
|
|
13612
13633
|
const data = await getJson('/api/preview', null, { query: { path: pane.target } });
|
|
13613
13634
|
if (data?.kind === 'html') {
|
|
13614
|
-
// The frame's origin is opaque, so the page restores its own scroll on load.
|
|
13615
|
-
const restore = scroll
|
|
13616
|
-
? `<script>addEventListener('load',()=>requestAnimationFrame(()=>scrollTo(${Number(scroll.x) || 0},${Number(scroll.y) || 0})))</script>`
|
|
13617
|
-
: '';
|
|
13618
13635
|
// A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
|
|
13619
13636
|
const [cls, doc] = pane.show
|
|
13620
13637
|
? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
|
|
13621
|
-
: ['pane-frame modal-zoomable', data.content
|
|
13638
|
+
: ['pane-frame modal-zoomable themed-frame', themedSrcdoc(data.content)];
|
|
13622
13639
|
const frame = createPreviewFrame(cls, doc);
|
|
13623
13640
|
frame.title = pane.title;
|
|
13624
13641
|
// Focus given before the document loads stays on the frame element, out of the document's keys.
|
|
13625
13642
|
frame.addEventListener('load', () => {
|
|
13626
13643
|
if (document.activeElement === frame) frame.contentWindow?.focus();
|
|
13644
|
+
if (scroll && !pane.show) postPaneScroll(frame, scroll);
|
|
13627
13645
|
});
|
|
13628
13646
|
view.appendChild(frame);
|
|
13629
13647
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
@@ -13842,8 +13860,13 @@ function syncPaneReview() {
|
|
|
13842
13860
|
|
|
13843
13861
|
function unmountPane(sid, paneId) {
|
|
13844
13862
|
const key = `${sid}/${paneId}`;
|
|
13845
|
-
paneFrames.get(key)
|
|
13863
|
+
const view = paneFrames.get(key);
|
|
13864
|
+
const hadFocus = view?.contains(document.activeElement);
|
|
13865
|
+
view?.remove();
|
|
13846
13866
|
paneFrames.delete(key);
|
|
13867
|
+
// Chrome leaves the window without focus when a focused frame is removed, so no key reaches
|
|
13868
|
+
// the board until a click. Only when it had focus: under the hub, cck may be off screen.
|
|
13869
|
+
if (hadFocus) window.focus();
|
|
13847
13870
|
}
|
|
13848
13871
|
|
|
13849
13872
|
function selectPane(id) {
|
|
@@ -13906,6 +13929,7 @@ function closeFocusedPane() {
|
|
|
13906
13929
|
const id = focusedPaneId();
|
|
13907
13930
|
if (!id || id === 'board') return false;
|
|
13908
13931
|
closePane(id);
|
|
13932
|
+
focusActivePane();
|
|
13909
13933
|
return true;
|
|
13910
13934
|
}
|
|
13911
13935
|
|
|
@@ -15997,7 +16021,7 @@ const showState = {
|
|
|
15997
16021
|
path: null,
|
|
15998
16022
|
};
|
|
15999
16023
|
let showSize = store.readJson(SHOW_SIZE_KEY);
|
|
16000
|
-
// Terminal id → { closed?, collapsed?, expanded? };
|
|
16024
|
+
// Terminal id → { closed?, collapsed?, expanded? }; a terminal with no entry yet starts collapsed. Ids gone from the terminal list are pruned.
|
|
16001
16025
|
const showViews = store.readJson(SHOW_VIEW_KEY, {});
|
|
16002
16026
|
// Older boards stored the string 'collapsed' or 'expanded'.
|
|
16003
16027
|
for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
|
|
@@ -16090,17 +16114,20 @@ function forgetEndedTerminals() {
|
|
|
16090
16114
|
if (!runningTerminals.has(showState.lookup)) showState.lookup = null;
|
|
16091
16115
|
}
|
|
16092
16116
|
|
|
16093
|
-
|
|
16117
|
+
// `post` is the id of the card on screen; a view without one keeps the saved id, so an empty list
|
|
16118
|
+
// while a terminal's posts load does not forget it.
|
|
16119
|
+
function setShowView(tid, view, post) {
|
|
16094
16120
|
const set = SHOW_VIEW_FLAGS.filter((f) => view[f]);
|
|
16095
|
-
const old = showViews[tid]
|
|
16096
|
-
|
|
16097
|
-
if (
|
|
16098
|
-
|
|
16121
|
+
const old = showViews[tid];
|
|
16122
|
+
const shown = post ?? old?.post;
|
|
16123
|
+
if (old && old.post === shown && SHOW_VIEW_FLAGS.every((f) => !!old[f] === set.includes(f))) return;
|
|
16124
|
+
showViews[tid] = Object.fromEntries(set.map((f) => [f, true]));
|
|
16125
|
+
if (shown) showViews[tid].post = shown;
|
|
16099
16126
|
store.writeJson(SHOW_VIEW_KEY, showViews);
|
|
16100
16127
|
}
|
|
16101
16128
|
|
|
16102
16129
|
function saveShowView() {
|
|
16103
|
-
if (showState.terminalId) setShowView(showState.terminalId, showState);
|
|
16130
|
+
if (showState.terminalId) setShowView(showState.terminalId, showState, showState.posts[showState.idx]?.id);
|
|
16104
16131
|
}
|
|
16105
16132
|
|
|
16106
16133
|
function resetShowPosts() {
|
|
@@ -16118,23 +16145,23 @@ function syncShowCard() {
|
|
|
16118
16145
|
}
|
|
16119
16146
|
const tid = pty ? (terminalIds.get(pty) ?? null) : null;
|
|
16120
16147
|
if (tid === showState.terminalId) return;
|
|
16121
|
-
const view = (tid && showViews[tid]) || {};
|
|
16148
|
+
const view = (tid && showViews[tid]) || { collapsed: true };
|
|
16122
16149
|
Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
|
|
16123
16150
|
for (const f of SHOW_VIEW_FLAGS) showState[f] = !!view[f];
|
|
16124
16151
|
resetShowPosts();
|
|
16125
16152
|
renderShow();
|
|
16126
|
-
if (tid) loadShowPosts(tid, {
|
|
16153
|
+
if (tid) loadShowPosts(tid, { shownId: view.post }).then((ok) => ok && renderShow());
|
|
16127
16154
|
}
|
|
16128
16155
|
|
|
16129
|
-
|
|
16156
|
+
// Opens `shownId` (by default the card on screen) when the list still holds it, else the newest.
|
|
16157
|
+
async function loadShowPosts(tid, { shownId = showState.posts[showState.idx]?.id } = {}) {
|
|
16130
16158
|
const seq = ++showState.listSeq;
|
|
16131
16159
|
const data = await getJson(apiPath`/api/terminals/${tid}/show`, null, { cache: 'no-store' });
|
|
16132
16160
|
if (seq !== showState.listSeq || tid !== showState.terminalId) return false;
|
|
16133
|
-
const shownId = showState.posts[showState.idx]?.id;
|
|
16134
16161
|
showState.sessionId = data?.sessionId ?? null;
|
|
16135
16162
|
showState.posts = Array.isArray(data?.posts) ? data.posts : [];
|
|
16136
16163
|
const at = showState.posts.findIndex((p) => p.id === shownId);
|
|
16137
|
-
showState.idx =
|
|
16164
|
+
showState.idx = at < 0 ? Math.max(0, showState.posts.length - 1) : at;
|
|
16138
16165
|
return true;
|
|
16139
16166
|
}
|
|
16140
16167
|
|
|
@@ -16207,10 +16234,11 @@ function showTicksHtml() {
|
|
|
16207
16234
|
}
|
|
16208
16235
|
|
|
16209
16236
|
function renderShow({ flash = false } = {}) {
|
|
16237
|
+
const n = showState.posts.length;
|
|
16238
|
+
if (n) showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
|
|
16210
16239
|
saveShowView();
|
|
16211
16240
|
const card = showCardEl();
|
|
16212
16241
|
const chip = document.getElementById('show-reopen');
|
|
16213
|
-
const n = showState.posts.length;
|
|
16214
16242
|
const open = n > 0 && !showState.closed;
|
|
16215
16243
|
chip.hidden = !n || open;
|
|
16216
16244
|
chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
|
|
@@ -16224,7 +16252,6 @@ function renderShow({ flash = false } = {}) {
|
|
|
16224
16252
|
card.replaceChildren();
|
|
16225
16253
|
return;
|
|
16226
16254
|
}
|
|
16227
|
-
showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
|
|
16228
16255
|
const p = showState.posts[showState.idx];
|
|
16229
16256
|
showState.marked.delete(p.id);
|
|
16230
16257
|
card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
|
|
@@ -16352,22 +16379,49 @@ function showTokens() {
|
|
|
16352
16379
|
return out;
|
|
16353
16380
|
}
|
|
16354
16381
|
|
|
16355
|
-
|
|
16356
|
-
|
|
16357
|
-
const tokens = Object.entries(showTokens())
|
|
16382
|
+
function showTokenCss() {
|
|
16383
|
+
return Object.entries(showTokens())
|
|
16358
16384
|
.map(([k, v]) => `${k}:${v}`)
|
|
16359
16385
|
.join(';');
|
|
16360
|
-
|
|
16386
|
+
}
|
|
16387
|
+
|
|
16388
|
+
function withHeadFirst(content, head) {
|
|
16389
|
+
const at = /<head[^>]*>/i.exec(content) || /<html[^>]*>/i.exec(content) || /<!doctype[^>]*>/i.exec(content);
|
|
16390
|
+
const inject = at?.[0].toLowerCase().startsWith('<head') ? head : `<head>${head}</head>`;
|
|
16391
|
+
const cut = at ? at.index + at[0].length : 0;
|
|
16392
|
+
return content.slice(0, cut) + inject + content.slice(cut);
|
|
16393
|
+
}
|
|
16394
|
+
|
|
16395
|
+
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
16396
|
+
function showSrcdoc(content, restoreY) {
|
|
16397
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}</style>`;
|
|
16361
16398
|
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
16362
16399
|
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
16363
16400
|
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
16364
16401
|
}
|
|
16365
|
-
|
|
16366
|
-
|
|
16367
|
-
|
|
16368
|
-
|
|
16402
|
+
return withHeadFirst(content, head) + bridge;
|
|
16403
|
+
}
|
|
16404
|
+
|
|
16405
|
+
// An HTML file preview gets only the tokens, first in its head: its own :root rules win, and
|
|
16406
|
+
// a page that declares its defaults in :where(:root) follows the board theme.
|
|
16407
|
+
function themedSrcdoc(content) {
|
|
16408
|
+
const head = `<style id="cck-tokens">:root{${showTokenCss()}}</style>`;
|
|
16409
|
+
return withHeadFirst(content, head) + THEME_BRIDGE_TAG + REVIEW_BRIDGE_TAG;
|
|
16369
16410
|
}
|
|
16370
16411
|
|
|
16412
|
+
// Runs inside the preview as source text, so it must not close over anything outside its own body.
|
|
16413
|
+
// It rewrites the injected rule, not inline styles, so the page's own :root still wins after a switch.
|
|
16414
|
+
function themeBridge(prefix) {
|
|
16415
|
+
addEventListener('message', (e) => {
|
|
16416
|
+
if (e.source !== parent || e.data?.type !== `${prefix}tokens`) return;
|
|
16417
|
+
const rule = document.getElementById('cck-tokens')?.sheet?.cssRules[0];
|
|
16418
|
+
if (!rule) return;
|
|
16419
|
+
for (const [k, v] of Object.entries(e.data.tokens)) rule.style.setProperty(k, v);
|
|
16420
|
+
});
|
|
16421
|
+
}
|
|
16422
|
+
|
|
16423
|
+
const THEME_BRIDGE_TAG = `<script>(${themeBridge})(${JSON.stringify(SHOW_MSG)});</script>`;
|
|
16424
|
+
|
|
16371
16425
|
// Runs inside the card as source text, so it must not close over anything outside its own body.
|
|
16372
16426
|
// Keys and links go through reviewBridge, which the card loads next to this.
|
|
16373
16427
|
function showBridge(restoreY, textZoom) {
|
|
@@ -16432,15 +16486,26 @@ window.addEventListener('message', (e) => {
|
|
|
16432
16486
|
});
|
|
16433
16487
|
|
|
16434
16488
|
// The same body attributes the terminal theme watches; a markdown card follows the board's CSS.
|
|
16489
|
+
// The body's style also changes on every drag, so a push with the same tokens is dropped.
|
|
16490
|
+
let sentTokens = '';
|
|
16435
16491
|
new MutationObserver(() => {
|
|
16436
|
-
postToShowFrames(() =>
|
|
16492
|
+
postToShowFrames(() => {
|
|
16493
|
+
const tokens = showTokens();
|
|
16494
|
+
const key = JSON.stringify(tokens);
|
|
16495
|
+
if (key === sentTokens) return null;
|
|
16496
|
+
sentTokens = key;
|
|
16497
|
+
return { type: `${SHOW_MSG}tokens`, tokens };
|
|
16498
|
+
});
|
|
16437
16499
|
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
|
|
16438
16500
|
|
|
16439
16501
|
// Builds the message only when a frame is there, because showTokens forces a style recalc.
|
|
16440
16502
|
function postToShowFrames(makeMsg) {
|
|
16441
|
-
const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame')].filter(
|
|
16503
|
+
const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame')].filter(
|
|
16504
|
+
(f) => f?.contentWindow,
|
|
16505
|
+
);
|
|
16442
16506
|
if (!frames.length) return;
|
|
16443
16507
|
const msg = makeMsg();
|
|
16508
|
+
if (!msg) return;
|
|
16444
16509
|
for (const f of frames) f.contentWindow.postMessage(msg, '*');
|
|
16445
16510
|
}
|
|
16446
16511
|
|
|
@@ -17218,8 +17283,9 @@ function onCallstackLoc(e) {
|
|
|
17218
17283
|
e.preventDefault();
|
|
17219
17284
|
e.stopPropagation();
|
|
17220
17285
|
if (e.shiftKey) window.getSelection().collapseToEnd();
|
|
17221
|
-
const
|
|
17222
|
-
const
|
|
17286
|
+
const onCard = !!el.closest('#show-card');
|
|
17287
|
+
const sid = onCard ? showState.sessionId : currentSessionId;
|
|
17288
|
+
const base = (onCard && showState.posts[showState.idx]?.base) || (sid && getSessionBaseDir(sid)) || undefined;
|
|
17223
17289
|
if (e.shiftKey || !sid || el.closest('.modal-overlay')) openPreviewAtLine(loc, base);
|
|
17224
17290
|
else openPaneAtLine(sid, loc, base);
|
|
17225
17291
|
}
|
package/public/style.css
CHANGED
package/server.js
CHANGED
|
@@ -70,6 +70,7 @@ const { pickFolder } = require('./lib/folder-dialog');
|
|
|
70
70
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
71
71
|
const { countTaskDir } = require('./lib/task-counts');
|
|
72
72
|
const { readTaskDir } = require('./lib/task-dir');
|
|
73
|
+
const { foldAgentLines } = require('./lib/agent-log');
|
|
73
74
|
const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
|
|
74
75
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
75
76
|
|
|
@@ -275,13 +276,7 @@ const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
|
|
|
275
276
|
// #region AGENT_ACTIVITY
|
|
276
277
|
|
|
277
278
|
function readAgentJsonl(filePath) {
|
|
278
|
-
|
|
279
|
-
const merged = {};
|
|
280
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
281
|
-
if (!line.trim()) continue;
|
|
282
|
-
try { Object.assign(merged, JSON.parse(line)); } catch (_) { /* skip malformed */ }
|
|
283
|
-
}
|
|
284
|
-
return merged;
|
|
279
|
+
return foldAgentLines(readFileSync(filePath, 'utf8'));
|
|
285
280
|
}
|
|
286
281
|
|
|
287
282
|
// Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
|