diff --git a/console/README.md b/console/README.md index dd3f82fa6..7810ab667 100644 --- a/console/README.md +++ b/console/README.md @@ -327,7 +327,11 @@ insertions, keyboard access, Themes, 390 px and Stages at 1920 px. Waterfall chooses one retained Command with live, died and ended chips, keeping its exact selection in `command=`. The chips fold under a Runs line that names -the Command shown; open it to choose another. A span tree replaces the old Run sections: +the Command shown; open it to choose another. Each chip and the selected Runs +line include the retained issue title when available, shortened with an ellipsis +to keep the choice brief; hover the title to read it in full. Older records +without a title and Commands without an issue keep their existing labels. +A span tree replaces the old Run sections: Command → Runs → stages → Sessions → model calls, turns, tools and sub-agents. Child Runs sit under the stage that started them; reused Sessions appear only under their first stage. Commands, Runs and stages start expanded, Sessions diff --git a/console/tests/waterfall.test.cjs b/console/tests/waterfall.test.cjs index 8ae16d2e3..45e116c6e 100644 --- a/console/tests/waterfall.test.cjs +++ b/console/tests/waterfall.test.cjs @@ -9,6 +9,7 @@ const { createInterface } = require('node:readline'); const { chromium } = require('./playwright.cjs'); let home, server, exited, browser, origin; +const longTitle = 'Display retained issue titles with Unicode 界 and ' + 'throughout the Waterfall '.repeat(10); before(async () => { home = await mkdtemp(join(tmpdir(), 'thirdshift-waterfall-')); const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); @@ -29,6 +30,9 @@ before(async () => { }; const parent = join(folder, '7-fixture.command.json'); await writeFile(parent, JSON.stringify(record)); + await writeFile(join(folder, '11-fixture.command.json'), JSON.stringify({ + ...record, name: 'run-11', issue: { ...record.issue, number: 11, title: longTitle } + })); const session = join(folder, '99-20261001T120000+0000-implement.logs.jsonl'); const spans = [ ['a', 'first ', '1790856015000000000', '1790856030000000000'], @@ -69,7 +73,7 @@ before(async () => { }] }] }] }) + '\n'); for (const number of [8, 9]) { await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify({ - ...record, kind: 'Run', name: `run-${number}`, issue: { ...record.issue, number }, + ...record, kind: 'Run', name: `run-${number}`, issue: { ...record.issue, number, title: number === 8 ? null : record.issue.title }, process_id: number === 9 ? process.pid : 0, process_start: number === 9 ? processStart : 'gone', outcome: null, ended_at: null, ...(number === 9 ? { started_at: liveStart, session_log: liveSession, @@ -162,13 +166,13 @@ test('Waterfall chips, nested scale and unknown tools work at desktop and 390 px await page.goto(`${origin}/?page=waterfall`); const runs = page.locator('details.command-choices'); assert.equal(await runs.evaluate(node => node.open), false, 'runs start folded'); - assert.match(await runs.locator('summary').textContent(), /^Runs \d+ · acme\/widgets#\d+ · \w+$/); + assert.match(await runs.locator('summary').textContent(), /^Runs \d+ · acme\/widgets#\d+ · Widgets · merged', exact: true }).click(); + await runs.locator('summary', { hasText: / · acme\/widgets#7 · Widgets '); + await runs.locator('summary').click(); + const chips = page.getByRole('navigation', { name: 'Choose a Command' }); + const long = chips.getByRole('link', { name: `acme/widgets#11 · ${longTitle} · merged`, exact: true }); + await long.waitFor(); + const title = long.locator('.command-title'); + assert.equal(await title.getAttribute('title'), longTitle); + await page.waitForFunction(text => [...document.querySelectorAll('.command-chips .command-title')].some(node => + node.textContent === text && node.scrollWidth > node.clientWidth && getComputedStyle(node).textOverflow === 'ellipsis' + ), longTitle); + assert.equal(await chips.locator('script').count(), 0, 'issue titles remain text'); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + const untitled = chips.getByRole('link', { name: 'acme/widgets#8 · died', exact: true }); + const untitledUrl = new URL(await untitled.getAttribute('href'), origin).href; + await untitled.click(); + await page.waitForURL(untitledUrl); + await runs.locator('summary', { hasText: /acme\/widgets#8 · died$/ }).waitFor(); + await runs.locator('summary').click(); + const longChoice = page.getByRole('link', { name: `acme/widgets#11 · ${longTitle} · merged`, exact: true }); + const longUrl = new URL(await longChoice.getAttribute('href'), origin).href; + await longChoice.click(); + await page.waitForURL(longUrl); + const selected = runs.locator('.chosen .command-title'); + await selected.waitFor(); + assert.equal(await selected.textContent(), longTitle); + assert.equal(await selected.getAttribute('title'), longTitle); + await page.waitForFunction(() => { + const title = document.querySelector('.chosen .command-title'); + return title?.isConnected && title.scrollWidth > title.clientWidth; + }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + const recordPath = join(home, '.thirdshift/logs/acme/widgets/commands/issue/11-fixture.command.json'); + const record = JSON.parse(await readFile(recordPath, 'utf8')); + record.issue.title = 'Updated retained title'; + await writeFile(recordPath, JSON.stringify(record)); + await page.locator('.chosen .command-title', { hasText: 'Updated retained title' }).waitFor({ state: 'attached' }); + await page.locator('details.command-choices > summary').click(); + await page.getByRole('link', { name: 'acme/widgets#11 · Updated retained title · merged', exact: true }).waitFor(); +}); diff --git a/src/console/assets/console.css b/src/console/assets/console.css index f4fc09c9d..62335b232 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -172,6 +172,8 @@ header::after { content: ""; display: block; height: 4px; background: var(--acti .command-choices { margin: 16px 0; } .command-choices > summary { cursor: pointer; } .command-choices .command-chips { margin: 12px 0 0; } +.command-choices .command-chips a { max-width: 100%; box-sizing: border-box; } +.command-title { display: inline-block; max-width: min(40ch, 100%); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } .stage-section { min-width: 0; margin: 20px 0; } .stage-section h3 { display: inline; margin-right: 16px; } .stage-scroll { overflow-x: auto; max-width: 100%; margin-top: 10px; overflow-anchor: none; } diff --git a/src/console/waterfall.html b/src/console/waterfall.html index 01095601a..f7b446204 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -15,8 +15,8 @@ {% include "console/nav.html" %}

Waterfall

thirdshift show <command> --spans --json · thirdshift logs <command>
-
Runs {{ view.choices.len() }}{% for choice in view.choices %}{% if choice.selected %} · {{ choice.subject }} · {{ choice.state }}{% endif %}{% endfor %} - +
Runs {{ view.choices.len() }}{% for choice in view.choices %}{% if choice.selected %} · {{ choice.subject }}{% match choice.title %}{% when Some with (title) %} · {{ title }}{% when None %}{% endmatch %} · {{ choice.state }}{% endif %}{% endfor %} +
{% if view.tree.spans.is_empty() %}

No Commands yet.

{% else %}

One shared time axis. * marks estimated times. Running calls extend to now.

diff --git a/src/logs/spans.rs b/src/logs/spans.rs index ece6bfa79..f1db9082d 100644 --- a/src/logs/spans.rs +++ b/src/logs/spans.rs @@ -60,6 +60,7 @@ pub(crate) struct Tree { #[derive(Serialize)] pub(crate) struct Choice { pub subject: String, + pub title: Option, pub state: String, pub url: String, pub selected: bool, @@ -231,6 +232,13 @@ pub(crate) fn load(config: &UserConfig, input: &str) -> Result { let choice = Observation::Record(command).selector().unwrap_or_default(); Choice { subject: subject(command), + title: command.record.issue.as_ref().and_then(|issue| { + issue + .title + .as_ref() + .filter(|title| !title.trim().is_empty()) + .cloned() + }), state: command .record .outcome diff --git a/tests/console/waterfall.rs b/tests/console/waterfall.rs index 61b9c7edb..b8ff99839 100644 --- a/tests/console/waterfall.rs +++ b/tests/console/waterfall.rs @@ -16,6 +16,42 @@ fn start_console(home: &Path, value: &Value) -> Console { Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home) } +#[test] +fn waterfall_choices_name_issues_and_keep_untitled_commands_selectable() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + record(home.path(), 7, Some("merged"), 0); + let mut untitled = record(home.path(), 8, Some("failed"), 1); + untitled["issue"]["title"] = Value::Null; + write_record(home.path(), 8, &untitled); + let mut experiment = record(home.path(), 9, Some("completed"), 2); + experiment["issue"] = Value::Null; + experiment["name"] = json!("experiment-fixture"); + write_record(home.path(), 9, &experiment); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let page = console + .get("/waterfall?command=widgets%237") + .text() + .unwrap(); + let choices = page + .split("
") + .nth(1) + .unwrap() + .split("
") + .next() + .unwrap(); + let title = "Widgets <script>alert(1)</script>"; + let (summary, chips) = choices.split_once("").unwrap(); + assert!(summary.contains(title), "{summary}"); + assert!(chips.contains(title), "{chips}"); + assert!(!choices.contains("