Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 58 additions & 5 deletions console/tests/waterfall.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>unsafe()</script> ' + 'throughout the Waterfall '.repeat(10);
before(async () => {
home = await mkdtemp(join(tmpdir(), 'thirdshift-waterfall-'));
const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue');
Expand All @@ -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 <unsafe>', '1790856015000000000', '1790856030000000000'],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <script>unsafe\(\)<\/script> · \w+$/);
await runs.locator('summary').click();
const chips = page.getByRole('navigation', { name: 'Choose a Command' });
assert.match(await chips.textContent(), /acme\/widgets#8 · died/);
if (process.platform === 'linux') assert.match(await chips.textContent(), /acme\/widgets#9 · live/);
await chips.getByRole('link', { name: 'acme/widgets#7 · merged' }).click();
await runs.locator('summary', { hasText: / · acme\/widgets#7 · merged$/ }).waitFor({ state: 'attached' });
if (process.platform === 'linux') assert.match(await chips.textContent(), /acme\/widgets#9 · Widgets <script>unsafe\(\)<\/script> · live/);
await chips.getByRole('link', { name: 'acme/widgets#7 · Widgets <script>unsafe()</script> · merged', exact: true }).click();
await runs.locator('summary', { hasText: / · acme\/widgets#7 · Widgets <script>unsafe\(\)<\/script> · merged$/ }).waitFor({ state: 'attached' });
await page.getByRole('button', { name: 'Every call', exact: true }).click();
assert.equal(await page.locator('.span-item[data-kind="run"]').count(), 2);
const child = page.locator('.span-item[data-kind="run"]').nth(1);
Expand Down Expand Up @@ -199,3 +203,52 @@ test('Waterfall chips, nested scale and unknown tools work at desktop and 390 px
}
assert.deepEqual(errors, []);
});

test('Waterfall issue titles stay brief, selectable and current at desktop and 390 px', async t => {
const context = await browser.newContext();
t.after(() => context.close());
const page = await context.newPage();
for (const width of [1200, 390]) {
await page.setViewportSize({ width, height: 844 });
await page.goto(`${origin}/waterfall?command=widgets%237`);
const runs = page.locator('details.command-choices');
assert.equal(await runs.locator('.chosen .command-title').textContent(), 'Widgets <script>unsafe()</script>');
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();
});
2 changes: 2 additions & 0 deletions src/console/assets/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions src/console/waterfall.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
{% include "console/nav.html" %}
<main data-time-page data-span-page>
<div class="page-title"><h2>Waterfall</h2><code>thirdshift show &lt;command&gt; --spans --json · thirdshift logs &lt;command&gt;</code></div>
<details class="command-choices"><summary>Runs <span class="count">{{ view.choices.len() }}</span>{% for choice in view.choices %}{% if choice.selected %} · <span class="chosen">{{ choice.subject }} · {{ choice.state }}</span>{% endif %}{% endfor %}</summary>
<nav class="command-chips" aria-label="Choose a Command">{% for choice in view.choices %}<a href="{{ choice.url }}" {% if choice.selected %}aria-current="true"{% endif %}>{{ choice.subject }} · {{ choice.state }}</a>{% endfor %}</nav>
<details class="command-choices"><summary>Runs <span class="count">{{ view.choices.len() }}</span>{% for choice in view.choices %}{% if choice.selected %} · <span class="chosen">{{ choice.subject }}{% match choice.title %}{% when Some with (title) %} · <span class="command-title" title="{{ title }}">{{ title }}</span>{% when None %}{% endmatch %} · {{ choice.state }}</span>{% endif %}{% endfor %}</summary>
<nav class="command-chips" aria-label="Choose a Command">{% for choice in view.choices %}<a href="{{ choice.url }}" {% if choice.selected %}aria-current="true"{% endif %}>{{ choice.subject }}{% match choice.title %}{% when Some with (title) %} · <span class="command-title" title="{{ title }}">{{ title }}</span>{% when None %}{% endmatch %} · {{ choice.state }}</a>{% endfor %}</nav>
</details>
{% if view.tree.spans.is_empty() %}<p>No Commands yet.</p>{% else %}
<p>One shared time axis. <span class="span-estimated">*</span> marks estimated times. Running calls extend to now.</p>
Expand Down
8 changes: 8 additions & 0 deletions src/logs/spans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub(crate) struct Tree {
#[derive(Serialize)]
pub(crate) struct Choice {
pub subject: String,
pub title: Option<String>,
pub state: String,
pub url: String,
pub selected: bool,
Expand Down Expand Up @@ -231,6 +232,13 @@ pub(crate) fn load(config: &UserConfig, input: &str) -> Result<View> {
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
Expand Down
36 changes: 36 additions & 0 deletions tests/console/waterfall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<details class=\"command-choices\">")
.nth(1)
.unwrap()
.split("</details>")
.next()
.unwrap();
let title = "Widgets &#60;script&#62;alert(1)&#60;/script&#62;";
let (summary, chips) = choices.split_once("</summary>").unwrap();
assert!(summary.contains(title), "{summary}");
assert!(chips.contains(title), "{chips}");
assert!(!choices.contains("<script>"), "{choices}");
assert!(choices.contains("acme/widgets#8 · failed</a>"), "{choices}");
assert!(
choices.contains("experiment-fixture · completed</a>"),
"{choices}"
);
}

#[test]
fn waterfall_reads_start_records_without_spans_and_ignores_an_unfinished_tail() {
let home = tempfile::tempdir().unwrap();
Expand Down
Loading