mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(story-mode): balloons over the speaker, honest boards, and drawers that keep up
From review round 1, checked against screenshots of the reviewers' real transcripts at desktop and phone width: - Claude's balloon is placed in the band above the head the scene drew, its tail pointing down at it, and clamped to stay inside the panel and off the head. Per-kind CSS offsets had covered the POW burst, the agent's head on a half-width fix panel and the first line of a terminal; a long prompt ran out of the mission panel. Save image clamps the same way. - A board only shows real counts; a check that reported none shows one verdict instead of 21 invented dots, and the loop shows its last run. - The mission panel opens the whole prompt, and on a task turn what the background job reported. - A background helper says "in the background" and "still working" until it reports back, its drawer keeps polling while it works, and the final panel counts helpers still out. - A fix made from the terminal opens what ran instead of an empty diff. - The wire files Claude's words under the panel that draws them, and its last words under the final panel. - A drawer follows its panel by the first event it showed when a refresh regroups panels (a check folding into a loop), not by kind. - Click regions reused for a new page get the new label; a new turn draws all its panels fresh. - Output links skip globs and paths a tool elided; prose links rooted paths only; paths inside the session folder read short (clicks keep the full path), and files Claude printed with cat open relative to that folder. - File cards keep a name's separators (rename_by_ / date.sh), code drops ligatures (`!==` stays three characters), counts drop "−0", and the turn picker's text stops before its chevron. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
38585a806b
commit
03cb3a5961
4 files changed
+391
-60
No files matched your search
+1
-1
@@ -54,7 +54,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`, `test/story-mode-ui.test.ts` (JSDOM, every scene drawn in every shape), `test/story-mode.browser.test.ts` (browser suite).
|
||||
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Claude's speech balloon is placed by `_storyPlaceBalloon()` in the band above the head `agent()` last drew (cyan body only), clamped (`--story-lines`) to stay inside the panel and off the head; never position it per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`, `test/story-mode-ui.test.ts` (JSDOM, every scene drawn in every shape), `test/story-mode.browser.test.ts` (browser suite).
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
|
||||
@@ -152,8 +152,9 @@ body.story-open {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
padding: 6px 8px;
|
||||
padding: 6px 26px 6px 8px;
|
||||
font: 500 12.5px/1.2 var(--story-mono);
|
||||
text-overflow: ellipsis;
|
||||
color: var(--story-ink);
|
||||
background: var(--story-panel);
|
||||
border: 2px solid var(--story-ink);
|
||||
@@ -341,6 +342,13 @@ body.story-open {
|
||||
.story-balloon[hidden] {
|
||||
display: none;
|
||||
}
|
||||
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */
|
||||
.story-balloon-text {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--story-lines, none);
|
||||
overflow: hidden;
|
||||
}
|
||||
.story-balloon::before,
|
||||
.story-balloon::after {
|
||||
content: '';
|
||||
@@ -408,17 +416,11 @@ body.story-open {
|
||||
.story-balloon--note::after {
|
||||
display: none;
|
||||
}
|
||||
.story-panel[data-kind='investigate'] .story-balloon--claude {
|
||||
right: auto;
|
||||
left: 30%;
|
||||
top: 30%;
|
||||
max-width: 38%;
|
||||
}
|
||||
.story-panel[data-kind='backup'] .story-balloon--claude {
|
||||
right: auto;
|
||||
left: 56%;
|
||||
top: 25%;
|
||||
max-width: 40%;
|
||||
/* Claude's balloon is placed over its head by story-mode.js (_storyPlaceBalloon); a scene with no
|
||||
Claude in it (the loop) keeps it in the top corner, clear of the art below. */
|
||||
.story-panel[data-kind='loop'] .story-balloon--claude {
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
}
|
||||
.story-panel[data-kind='ask'] .story-balloon--claude {
|
||||
right: auto;
|
||||
@@ -790,6 +792,7 @@ body.story-open {
|
||||
.story-out-cmd {
|
||||
flex: 1 1 220px;
|
||||
font: 500 12.5px/1.35 var(--story-mono);
|
||||
font-variant-ligatures: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.story-cnt {
|
||||
@@ -803,6 +806,8 @@ body.story-open {
|
||||
}
|
||||
.story-code {
|
||||
font: 12px/1.65 var(--story-mono);
|
||||
/* Code as written: `!==` stays three characters. */
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
.story-ln {
|
||||
display: grid;
|
||||
@@ -868,9 +873,15 @@ body.story-open {
|
||||
margin: 0;
|
||||
padding: 8px 18px;
|
||||
font: 12px/1.6 var(--story-mono);
|
||||
font-variant-ligatures: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.story-prompt {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.story-h {
|
||||
margin: 16px 18px 0;
|
||||
font: 17px/1.1 var(--story-letter);
|
||||
|
||||
+261
-47
@@ -312,12 +312,21 @@ const StoryArt = (() => {
|
||||
}
|
||||
}
|
||||
/** The agent: a terminal-headed character. (x, y) is the point between its feet. */
|
||||
/** Where Claude's head was last drawn (stage units): the speech balloon is placed over it. */
|
||||
let speaker = null;
|
||||
function takeSpeaker() {
|
||||
const s = speaker;
|
||||
speaker = null;
|
||||
return s;
|
||||
}
|
||||
function agent(ctx, x, y, o = {}) {
|
||||
const s = o.scale ?? 1;
|
||||
const body = o.body ?? C.cyan;
|
||||
const L = o.l ?? [-32, -44];
|
||||
const R = o.r ?? [32, -44];
|
||||
const hy = -120 + (o.headBob ?? 0);
|
||||
// Claude is the cyan one; a helper in another colour never holds the balloon.
|
||||
if (body === C.cyan) speaker = { x, top: y + (hy - 36) * s };
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(o.tilt ?? 0);
|
||||
@@ -372,18 +381,20 @@ const StoryArt = (() => {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const stem = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
// Break after a separator or between camelCase words, keeping every character of the name.
|
||||
const words = stem
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean);
|
||||
.replace(/([a-z])([A-Z])/g, '$1\u0001$2')
|
||||
.split('\u0001')
|
||||
.flatMap((part) => part.match(/[^\s_.-]+[\s_.-]*|[\s_.-]+/g) || []);
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if ((cur + w).length > 9 && cur) {
|
||||
lines.push(cur);
|
||||
if ((cur + w).trimEnd().length > 9 && cur) {
|
||||
lines.push(cur.trimEnd());
|
||||
cur = w;
|
||||
} else cur += w;
|
||||
}
|
||||
cur = cur.trimEnd();
|
||||
if ((cur + ext).length > 11) lines.push(cur, ext);
|
||||
else lines.push(cur + ext);
|
||||
return lines.filter(Boolean).slice(0, 3);
|
||||
@@ -566,6 +577,51 @@ const StoryArt = (() => {
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* A check without counts (a build, a lint, a run whose summary was not recognised): one verdict
|
||||
* stamp instead of a board, so no number is drawn that the run never reported.
|
||||
*/
|
||||
function verdict(ctx, x, y, label, ok, f) {
|
||||
const w = 127;
|
||||
const h = 92;
|
||||
rr(ctx, x, y, w, h, 8);
|
||||
inked(ctx, C.balloon, 2.5);
|
||||
ctx.fillStyle = C.balloonInk;
|
||||
ctx.font = `15px ${LETTER}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(fitText(ctx, label, w - 12), x + w / 2, y + 14);
|
||||
const cx = x + w / 2;
|
||||
const cy = y + 56;
|
||||
const r = 22;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, TAU);
|
||||
inked(ctx, ok === undefined ? C.balloon : ok ? C.go : C.magenta, 3);
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.strokeStyle = ok === undefined ? C.line : C.balloon;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
if (ok === undefined) {
|
||||
// Running: a hand that sweeps round the dial.
|
||||
const a = (f / 9) % TAU;
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(Math.cos(a) * (r - 7), Math.sin(a) * (r - 7));
|
||||
} else if (ok) {
|
||||
ctx.moveTo(-10, 1);
|
||||
ctx.lineTo(-3, 8);
|
||||
ctx.lineTo(11, -8);
|
||||
} else {
|
||||
ctx.moveTo(-8, -8);
|
||||
ctx.lineTo(8, 8);
|
||||
ctx.moveTo(8, -8);
|
||||
ctx.lineTo(-8, 8);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return { w, h };
|
||||
}
|
||||
/** A board of check dots. `fail` dots are drawn red with a crack; `fill` is how many are in yet. */
|
||||
function board(ctx, x, y, label, dots, fail, fill) {
|
||||
const cols = 7;
|
||||
@@ -677,8 +733,9 @@ const StoryArt = (() => {
|
||||
base(ctx, W, H);
|
||||
halftone(ctx, W, H, C.cyan, W, H, { rmax: 4.4, reach: 0.9 });
|
||||
},
|
||||
draw(ctx, W, H, f) {
|
||||
draw(ctx, W, H, f, p, hit) {
|
||||
const ax = Math.max(W * 0.78, W - 104);
|
||||
hit('prompt', ax - 56, 110, 112, 140, p.page?.origin === 'task' ? 'Read the report' : 'Read the whole prompt');
|
||||
const a = agent(ctx, ax, 244, {
|
||||
frame: f,
|
||||
mood: 'listen',
|
||||
@@ -916,7 +973,7 @@ const StoryArt = (() => {
|
||||
p.director,
|
||||
(pr - 0.85) / 0.15
|
||||
);
|
||||
hit(`helper:${h.event}`, x - 30, y - 112, 60, 142, `See what ${h.name} did`);
|
||||
hit(`helper:${h.event}`, x - 30, y - 112, 60, 142, `See what the helper did: ${h.task || h.name}`);
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -973,15 +1030,19 @@ const StoryArt = (() => {
|
||||
draw(ctx, W, H, f, p, hit) {
|
||||
const t = p.panel.tests;
|
||||
const total = t ? t.passed + t.failed : 0;
|
||||
const dots = total ? Math.min(42, Math.max(7, total)) : 21;
|
||||
const fail = p.panel.ok === false ? (t && total ? Math.max(1, Math.round((t.failed / total) * dots)) : 1) : 0;
|
||||
const running = p.panel.ok === undefined;
|
||||
const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6);
|
||||
const label = total ? `${total} checks` : 'checks';
|
||||
const b = board(ctx, 16, 106, label, dots, fail, fill);
|
||||
let b;
|
||||
if (total) {
|
||||
const dots = Math.min(42, Math.max(7, total));
|
||||
const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0;
|
||||
const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6);
|
||||
b = board(ctx, 16, 106, `${total} checks`, dots, fail, fill);
|
||||
} else {
|
||||
b = verdict(ctx, 16, 106, 'checks', p.panel.ok, f);
|
||||
}
|
||||
hit('output', 16, 106, b.w, b.h, 'See the output');
|
||||
const cx = Math.min(W * 0.62, W - 100);
|
||||
const cy = 150;
|
||||
const cx = Math.min(W * 0.56, W - 120);
|
||||
const cy = 166;
|
||||
const bp = running ? 0 : spring(f - 30, { damping: 9, stiffness: 150 });
|
||||
if (bp > 0.01) {
|
||||
ctx.save();
|
||||
@@ -1024,8 +1085,15 @@ const StoryArt = (() => {
|
||||
hit('diff', 12, 108, pw, 112, "See Claude's changes");
|
||||
const bx = 12 + pw + 34;
|
||||
if (bx + 127 < W - 10) {
|
||||
const dots = 14;
|
||||
const b = board(ctx, bx, 116, 'checks', dots, p.panel.ok === false ? 2 : 0, Math.min(dots, (f - 4) * 1.6));
|
||||
// The last run's real counts, or one verdict when it reported none.
|
||||
const t = p.panel.tests;
|
||||
const total = t ? t.passed + t.failed : 0;
|
||||
let b;
|
||||
if (total) {
|
||||
const dots = Math.min(28, Math.max(7, total));
|
||||
const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0;
|
||||
b = board(ctx, bx, 116, `${total} checks`, dots, fail, Math.min(dots, (f - 4) * 1.6));
|
||||
} else b = verdict(ctx, bx, 116, 'checks', p.panel.ok, f);
|
||||
hit('output', bx, 116, b.w, b.h, 'See the test output');
|
||||
}
|
||||
// The cycle arrow between the page and the board.
|
||||
@@ -1043,11 +1111,12 @@ const StoryArt = (() => {
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
// Rounds and verdict stack under the top corner, which the balloon may take.
|
||||
sfx(
|
||||
ctx,
|
||||
`×${p.panel.rounds || 1}`,
|
||||
W - 60,
|
||||
96,
|
||||
214,
|
||||
30,
|
||||
C.yellow,
|
||||
-0.1,
|
||||
@@ -1289,7 +1358,7 @@ const StoryArt = (() => {
|
||||
},
|
||||
};
|
||||
|
||||
return { FPS, HERO, VH, C, readColors, spring, SCENES };
|
||||
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker };
|
||||
})();
|
||||
|
||||
/** Panel kinds as the wire's group headings name them. */
|
||||
@@ -1727,8 +1796,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
st.raf = requestAnimationFrame(loop);
|
||||
st.pollTimer = setInterval(() => {
|
||||
const page = st.data?.page;
|
||||
if (!st.open || document.hidden || st.turnId) return;
|
||||
if (!page || !page.complete) this._storyRefresh();
|
||||
if (!st.open || document.hidden) return;
|
||||
if (!st.turnId && (!page || !page.complete)) {
|
||||
this._storyRefresh();
|
||||
return;
|
||||
}
|
||||
if (st.drawer?.view === 'helper') {
|
||||
const ev = page?.events.find((e) => e.id === st.drawer.event);
|
||||
if (ev?.pending || ev?.helper?.running) this._storyRenderDrawer(false);
|
||||
}
|
||||
}, STORY_POLL_MS);
|
||||
st.keyHandler = (e) => {
|
||||
if (!st.open) return;
|
||||
@@ -1860,6 +1936,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
2
|
||||
);
|
||||
const fresh = !st.first;
|
||||
if (st.shownPageId !== (page?.id ?? null)) {
|
||||
for (const rt of st.panels.splice(0)) this._storyDropPanel(rt);
|
||||
st.shownPageId = page?.id ?? null;
|
||||
}
|
||||
want.forEach((panel, i) => {
|
||||
let rt = st.panels[i];
|
||||
if (rt && rt.kind !== panel.kind) {
|
||||
@@ -1981,7 +2061,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
rt.capDir.textContent = p.caption.director || p.caption.story;
|
||||
const balloonText = p.kind === 'ask' && p.question?.text ? p.question.text : p.balloon?.text;
|
||||
rt.balloon.hidden = !balloonText;
|
||||
rt.balloon.textContent = balloonText || '';
|
||||
// The text sits in its own box so a long one can be clamped without clipping the tail.
|
||||
let balloonSpan = rt.balloon.firstElementChild;
|
||||
if (!balloonSpan) {
|
||||
balloonSpan = document.createElement('span');
|
||||
balloonSpan.className = 'story-balloon-text';
|
||||
rt.balloon.append(balloonSpan);
|
||||
}
|
||||
balloonSpan.textContent = balloonText || '';
|
||||
const notice = p.kind === 'mission' && index === 0 && page.origin && page.origin !== 'human';
|
||||
rt.balloon.className = `story-balloon ${
|
||||
notice
|
||||
@@ -2008,7 +2095,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
open: 'diff:page',
|
||||
});
|
||||
items.push({ text: `${page.toolCalls} tool call${page.toolCalls === 1 ? '' : 's'}`, open: 'list:page' });
|
||||
if (page.helpers) items.push({ text: `${page.helpers} helper${page.helpers === 1 ? '' : 's'}` });
|
||||
if (page.helpers)
|
||||
items.push({
|
||||
text: `${page.helpers} helper${page.helpers === 1 ? '' : 's'}${
|
||||
page.helpersRunning ? `, ${page.helpersRunning} still working` : ''
|
||||
}`,
|
||||
});
|
||||
chips.replaceChildren(
|
||||
...items.map((item) => {
|
||||
const li = document.createElement('li');
|
||||
@@ -2074,6 +2166,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ctx.setTransform(k, 0, 0, k, ox * k, 0);
|
||||
const hits = [];
|
||||
const hit = (id, x, y, w, h, label) => hits.push({ id, x: x + ox, y, w, h, label });
|
||||
StoryArt.takeSpeaker();
|
||||
scene.draw(
|
||||
ctx,
|
||||
stageW,
|
||||
@@ -2083,6 +2176,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
hit
|
||||
);
|
||||
this._storySyncHits(rt, hits, s);
|
||||
this._storyPlaceBalloon(rt, StoryArt.takeSpeaker(), ox, s);
|
||||
},
|
||||
|
||||
/**
|
||||
* Claude's balloon goes in the band above its head, the tail pointing down at it, so it never
|
||||
* covers the art the scene draws below (a test board, a burst, the code page). It keeps clear of
|
||||
* the caption box: narrower beside it, or below it when there is no room. Recomputed only when
|
||||
* the panel's size, the text or the head's column changes.
|
||||
*/
|
||||
_storyPlaceBalloon(rt, head, ox, s) {
|
||||
const b = rt.balloon;
|
||||
if (!b || b.hidden || rt.splash) return;
|
||||
const placed = !!head && rt.kind !== 'ask' && b.classList.contains('story-balloon--claude');
|
||||
const hx = placed ? (head.x + ox) * s : 0;
|
||||
const cap = rt.capStory.parentElement;
|
||||
const capW = cap?.offsetWidth || 0;
|
||||
const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${b.className}|${b.textContent}`;
|
||||
if (key === rt.balloonKey) return;
|
||||
rt.balloonKey = key;
|
||||
if (placed) {
|
||||
const capRight = cap ? cap.offsetLeft + capW : 0;
|
||||
const capBottom = cap ? cap.offsetTop + cap.offsetHeight : 0;
|
||||
const right = Math.max(10, Math.min(rt.w - 160, rt.w - hx - 38));
|
||||
let maxW = Math.min(rt.w * 0.5, rt.w - right - 14);
|
||||
let top = 10;
|
||||
const besideCap = rt.w - right - capRight - 10;
|
||||
if (besideCap >= 150) maxW = Math.min(maxW, besideCap);
|
||||
else top = capBottom + 8;
|
||||
b.style.left = 'auto';
|
||||
b.style.right = `${Math.round(right)}px`;
|
||||
b.style.top = `${Math.round(top)}px`;
|
||||
b.style.maxWidth = `${Math.round(Math.max(140, maxW))}px`;
|
||||
} else {
|
||||
for (const prop of ['left', 'right', 'top', 'max-width']) b.style.removeProperty(prop);
|
||||
}
|
||||
// Clamp a long text: inside the panel always, and off Claude's head when three lines allow.
|
||||
const text = b.firstElementChild;
|
||||
if (!text) return;
|
||||
text.style.removeProperty('--story-lines');
|
||||
const lh = parseFloat(getComputedStyle(text).lineHeight) || 19;
|
||||
const pad = b.offsetHeight - text.offsetHeight;
|
||||
const limit = placed ? Math.max(b.offsetTop + pad + 3 * lh, head.top * s - 22) : rt.h - 12;
|
||||
if (b.offsetHeight && b.offsetTop + b.offsetHeight > limit)
|
||||
text.style.setProperty('--story-lines', String(Math.max(2, Math.floor((limit - b.offsetTop - pad) / lh))));
|
||||
},
|
||||
|
||||
/** Canvas click regions become real buttons: focusable, labelled, keyboard-reachable. */
|
||||
@@ -2095,11 +2232,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'story-hit';
|
||||
b.setAttribute('aria-label', h.label);
|
||||
b.title = h.label;
|
||||
rt.el.appendChild(b);
|
||||
rt.hitEls.set(h.id, b);
|
||||
}
|
||||
if (b.title !== h.label) {
|
||||
b.setAttribute('aria-label', h.label);
|
||||
b.title = h.label;
|
||||
}
|
||||
b.dataset.storyOpen = `${h.id}@${rt.el.dataset.kind}:${this._story.panels.indexOf(rt)}`;
|
||||
const L = Math.round(h.x * s);
|
||||
const T = Math.round(h.y * s);
|
||||
@@ -2255,6 +2394,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
const padB = parseFloat(cs.paddingBottom) + (parseFloat(cs.borderBottomWidth) || 0);
|
||||
const fit = Math.max(1, Math.floor((r.height - padT - padB + 2) / lh));
|
||||
if (!isTitle && lines.length > fit) {
|
||||
lines.length = fit;
|
||||
lines[fit - 1] = this._storyFit(g, `${lines[fit - 1]}…`, maxW);
|
||||
}
|
||||
if (isTitle) {
|
||||
g.save();
|
||||
// Rotated about its bottom-left corner: draw from there, with the element's own height (the
|
||||
@@ -2293,10 +2438,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const panelOf = new Map();
|
||||
page.panels.forEach((p, i) => p.events.forEach((e) => panelOf.set(e, i)));
|
||||
// A sentence Claude wrote belongs to the panel after it (that is where its balloon is drawn);
|
||||
// after the last tool call, to the final panel.
|
||||
const finalPanel = page.panels[page.panels.length - 1]?.kind === 'complete' ? page.panels.length - 1 : -1;
|
||||
let next = finalPanel >= 0 ? finalPanel : page.panels.length - 1;
|
||||
for (let i = page.events.length - 1; i >= 0; i--) {
|
||||
if (panelOf.has(i)) next = panelOf.get(i);
|
||||
else panelOf.set(i, next);
|
||||
}
|
||||
const rows = [];
|
||||
let lastPanel = -2;
|
||||
page.events.forEach((ev, i) => {
|
||||
const pi = panelOf.has(i) ? panelOf.get(i) : lastPanel;
|
||||
const pi = panelOf.get(i);
|
||||
if (pi !== lastPanel && pi >= 0) {
|
||||
const g = document.createElement('li');
|
||||
g.className = 'story-wire-group';
|
||||
@@ -2387,7 +2540,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!panel) return;
|
||||
if (what.startsWith('file:')) {
|
||||
const file = panel.files?.[Number(what.slice(5))];
|
||||
if (file) this.openFilePreview?.(file, st.sessionId);
|
||||
if (file) this.openFilePreview?.(this._storyResolvePath(file), st.sessionId);
|
||||
return;
|
||||
}
|
||||
const [view, arg] = what.split(':');
|
||||
@@ -2395,7 +2548,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._storyOpenDrawer({ view: 'helper', event: page.events[Number(arg)]?.id }, trigger);
|
||||
return;
|
||||
}
|
||||
this._storyOpenDrawer({ view, panel: panelIndex, kind: panel.kind }, trigger);
|
||||
this._storyOpenDrawer(
|
||||
{ view, panel: panelIndex, kind: panel.kind, first: page.events[panel.events[0]]?.id },
|
||||
trigger
|
||||
);
|
||||
return;
|
||||
}
|
||||
const [view, scope, arg] = target.split(':');
|
||||
@@ -2406,12 +2562,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const ev = page.events[Number(arg)];
|
||||
if (!ev) return;
|
||||
if (view === 'file') {
|
||||
this.openFilePreview?.(ev.file, st.sessionId);
|
||||
this.openFilePreview?.(this._storyResolvePath(ev.file), st.sessionId);
|
||||
return;
|
||||
}
|
||||
this._storyOpenDrawer({ view, event: ev.id }, trigger);
|
||||
},
|
||||
|
||||
/** A path as a transcript named it, made absolute against the session's folder when relative. */
|
||||
_storyResolvePath(path) {
|
||||
const workingDir = this.sessions?.get(this._story?.sessionId)?.workingDir;
|
||||
return typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(path, workingDir) : path;
|
||||
},
|
||||
|
||||
_storyOpenDrawer(source, trigger) {
|
||||
const st = this._story;
|
||||
if (!st.drawer) st.drawerReturn = trigger || null;
|
||||
@@ -2444,9 +2606,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
let indices;
|
||||
if (source.page) indices = page.events.map((_, i) => i);
|
||||
else {
|
||||
// A refresh can regroup panels (a check folds into a loop): follow the panel that holds the
|
||||
// first event it showed, then fall back to the same index and kind.
|
||||
const firstIdx = source.first ? page.events.findIndex((e) => e.id === source.first) : -1;
|
||||
const panel = page.panels[source.panel];
|
||||
// A refresh can regroup panels; follow the panel by kind if the index moved.
|
||||
const match = panel && panel.kind === source.kind ? panel : page.panels.find((p) => p.kind === source.kind);
|
||||
const match =
|
||||
(firstIdx >= 0 && page.panels.find((p) => p.events.includes(firstIdx))) ||
|
||||
(panel && panel.kind === source.kind ? panel : page.panels.find((p) => p.kind === source.kind));
|
||||
indices = match ? match.events : [];
|
||||
}
|
||||
return indices.map((i) => ({ ev: page.events[i], i })).filter((x) => x.ev);
|
||||
@@ -2475,14 +2641,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
let firstNew = null;
|
||||
const isNew = (id) => !first && !st.drawerSeen.has(id);
|
||||
|
||||
if (source.view === 'diff') {
|
||||
// Changes made from the terminal (`sed -i`, a redirect) have no diff to show: show what ran.
|
||||
const view =
|
||||
source.view === 'diff' && items.length && !items.some((x) => x.ev.edit || x.ev.pending) ? 'output' : source.view;
|
||||
if (view === 'prompt') {
|
||||
const prompts = items.filter((x) => x.ev.intent === 'prompt');
|
||||
const task = page.origin === 'task' && prompts[0]?.ev.id === page.events[0]?.id;
|
||||
kind = task ? 'Report' : 'Mission';
|
||||
title = task ? 'A background job reports back' : prompts.length > 1 ? 'What you asked' : 'Your prompt';
|
||||
for (const { ev } of prompts) {
|
||||
out.push(el('pre', 'story-pre story-prompt', ev.label));
|
||||
if (ev.output !== undefined) {
|
||||
out.push(el('h4', 'story-h', 'What it reported'), this._storyLinkifiedPre(ev.output, { rooted: true }));
|
||||
if (ev.outputTruncated) out.push(el('p', 'story-empty', 'The report is cut here.'));
|
||||
}
|
||||
}
|
||||
if (prompts.some((x) => x.ev.label.length >= 599))
|
||||
out.push(el('p', 'story-empty', 'A long prompt is cut here; the terminal has all of it.'));
|
||||
} else if (view === 'diff') {
|
||||
const edits = items.filter((x) => x.ev.edit);
|
||||
kind = live ? 'Live diff' : 'Diff';
|
||||
const added = edits.reduce((n, x) => n + x.ev.edit.added, 0);
|
||||
const removed = edits.reduce((n, x) => n + x.ev.edit.removed, 0);
|
||||
const files = new Set(edits.map((x) => x.ev.edit.file)).size;
|
||||
title = files === 1 ? edits[0].ev.edit.file : `${files} files`;
|
||||
sub = `${edits.length} edit${edits.length === 1 ? '' : 's'} · +${added} −${removed}${live ? ' · new edits slide in as Claude makes them' : ''}`;
|
||||
const counts = [added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0';
|
||||
sub = `${edits.length} edit${edits.length === 1 ? '' : 's'} · ${counts}${live ? ' · new edits slide in as Claude makes them' : ''}`;
|
||||
if (!edits.length) out.push(el('p', 'story-empty', 'No changes in this part of the turn yet.'));
|
||||
for (const { ev } of edits) {
|
||||
const section = this._storyDiffSection(ev, el);
|
||||
@@ -2492,9 +2676,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
out.push(section);
|
||||
}
|
||||
} else if (source.view === 'output' || source.view === 'list') {
|
||||
} else if (view === 'output' || view === 'list') {
|
||||
const withOutput = items.filter((x) => x.ev.tool !== 'Text' && x.ev.intent !== 'prompt');
|
||||
kind = source.view === 'list' ? 'Steps' : 'Output';
|
||||
kind = view === 'list' ? 'Steps' : 'Output';
|
||||
title = source.page ? 'Every step of this turn' : STORY_KIND_NAMES[source.kind] || 'Steps';
|
||||
sub = `${withOutput.length} step${withOutput.length === 1 ? '' : 's'}`;
|
||||
if (!withOutput.length) out.push(el('p', 'story-empty', 'Nothing here yet.'));
|
||||
@@ -2506,26 +2690,41 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
out.push(section);
|
||||
}
|
||||
} else if (source.view === 'helper') {
|
||||
} else if (view === 'helper') {
|
||||
const ev = items[0]?.ev;
|
||||
kind = 'Helper';
|
||||
title = ev?.helper ? ev.helper.task || ev.helper.name : 'Helper';
|
||||
const h = ev?.helper;
|
||||
const working = !!(ev?.pending || h?.running);
|
||||
sub = h
|
||||
? [
|
||||
h.name && h.name !== h.type ? `${h.name}, ${h.type}` : h.type,
|
||||
h.toolUses !== undefined ? `${h.toolUses} tool calls` : '',
|
||||
h.background ? 'in the background' : '',
|
||||
h.toolUses !== undefined ? `${h.toolUses} tool call${h.toolUses === 1 ? '' : 's'}` : '',
|
||||
h.durationMs !== undefined ? this._storyDuration(h.durationMs) : '',
|
||||
ev.pending ? 'still working' : '',
|
||||
working ? 'still working' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
if (h) {
|
||||
out.push(el('h4', 'story-h', 'Claude asked'), el('pre', 'story-pre', h.prompt || '(no prompt)'));
|
||||
out.push(
|
||||
el('h4', 'story-h', 'Claude asked'),
|
||||
this._storyLinkifiedPre(h.prompt || '(no prompt)', { rooted: true })
|
||||
);
|
||||
out.push(
|
||||
el('h4', 'story-h', 'The helper answered'),
|
||||
this._storyLinkifiedPre(h.result || (ev.pending ? 'Still working…' : '(no answer)'))
|
||||
h.result
|
||||
? this._storyLinkifiedPre(h.result, { rooted: true })
|
||||
: el(
|
||||
'p',
|
||||
'story-empty',
|
||||
h.running
|
||||
? 'Still working in the background. Its answer comes back when it finishes.'
|
||||
: working
|
||||
? 'Still working…'
|
||||
: 'No answer came back.'
|
||||
)
|
||||
);
|
||||
if (h.agentId) {
|
||||
const box = el('div', 'story-helper-steps');
|
||||
@@ -2533,7 +2732,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._storyFillHelperSteps(box, h.agentId);
|
||||
}
|
||||
}
|
||||
} else if (source.view === 'plan') {
|
||||
} else if (view === 'plan') {
|
||||
const ev = [...items].reverse().find((x) => x.ev.todos)?.ev;
|
||||
kind = 'Plan';
|
||||
title = 'The plan';
|
||||
@@ -2544,7 +2743,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (!ev) for (const x of items) ul.append(el('li', '', x.ev.label));
|
||||
out.push(ul);
|
||||
} else if (source.view === 'ask') {
|
||||
} else if (view === 'ask') {
|
||||
const ev = items.find((x) => x.ev.question)?.ev;
|
||||
kind = 'Your call';
|
||||
title = ev?.question?.text || 'Question';
|
||||
@@ -2720,27 +2919,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
return section;
|
||||
},
|
||||
|
||||
/** Output text with every file path as a link into the file preview (the terminal's own matcher). */
|
||||
_storyLinkifiedPre(text) {
|
||||
/**
|
||||
* Output text with every file path as a link into the file preview (the terminal's own matcher).
|
||||
* `rooted` keeps only absolute and `~/` paths: in prose (a prompt, a helper's answer) a bare name is
|
||||
* as often an example (`old.jpg`) as a file.
|
||||
*/
|
||||
_storyLinkifiedPre(text, { rooted = false } = {}) {
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'story-pre';
|
||||
const value = String(text || '');
|
||||
const links = typeof findFilePathLinks === 'function' ? findFilePathLinks(value).filter((l) => !l.folder) : [];
|
||||
// A glob (`*.test.ts`) or a path a tool elided (`2...rted_0/a.txt`) names no one file.
|
||||
const links =
|
||||
typeof findFilePathLinks === 'function'
|
||||
? findFilePathLinks(value).filter(
|
||||
(l) =>
|
||||
!l.folder &&
|
||||
(!rooted || /^(\/|~\/)/.test(l.path)) &&
|
||||
!/\.\.\.|…|[*?]/.test(l.path) &&
|
||||
!/[*?]$/.test(value.slice(0, l.index))
|
||||
)
|
||||
: [];
|
||||
const workingDir = this.sessions?.get(this._story.sessionId)?.workingDir;
|
||||
let last = 0;
|
||||
for (const link of links) {
|
||||
pre.append(document.createTextNode(value.slice(last, link.index)));
|
||||
pre.append(document.createTextNode(this._storyShortPath(value.slice(last, link.index))));
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'story-link';
|
||||
b.textContent = link.path;
|
||||
b.textContent = this._storyShortPath(link.path);
|
||||
if (b.textContent !== link.path) b.title = link.path;
|
||||
const resolved =
|
||||
typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(link.path, workingDir) : link.path;
|
||||
b.addEventListener('click', () => this.openFilePreview?.(resolved, this._story.sessionId));
|
||||
pre.append(b);
|
||||
last = link.index + link.path.length;
|
||||
}
|
||||
pre.append(document.createTextNode(value.slice(last)));
|
||||
pre.append(document.createTextNode(this._storyShortPath(value.slice(last))));
|
||||
return pre;
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user