@contraagent/ui 0.9.1 → 0.9.2

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.
Files changed (73) hide show
  1. package/custom-elements.json +179 -3
  2. package/dist/adapters/filters.d.ts.map +1 -1
  3. package/dist/adapters/filters.js +3 -1
  4. package/dist/adapters/filters.js.map +1 -1
  5. package/dist/adapters/graph/compound.d.ts +2 -0
  6. package/dist/adapters/graph/compound.d.ts.map +1 -1
  7. package/dist/adapters/graph/compound.js +12 -1
  8. package/dist/adapters/graph/compound.js.map +1 -1
  9. package/dist/adapters/graph/layer.d.ts.map +1 -1
  10. package/dist/adapters/graph/layer.js +6 -1
  11. package/dist/adapters/graph/layer.js.map +1 -1
  12. package/dist/adapters/graph/matrix.d.ts.map +1 -1
  13. package/dist/adapters/graph/matrix.js +43 -9
  14. package/dist/adapters/graph/matrix.js.map +1 -1
  15. package/dist/adapters/lanes.d.ts.map +1 -1
  16. package/dist/adapters/lanes.js +26 -9
  17. package/dist/adapters/lanes.js.map +1 -1
  18. package/dist/adapters/panel-body.d.ts.map +1 -1
  19. package/dist/adapters/panel-body.js +15 -3
  20. package/dist/adapters/panel-body.js.map +1 -1
  21. package/dist/contraagent-ui.js +683 -683
  22. package/dist/elements/lists/ca-contracts.d.ts +4 -0
  23. package/dist/elements/lists/ca-contracts.d.ts.map +1 -1
  24. package/dist/elements/lists/ca-contracts.js +33 -13
  25. package/dist/elements/lists/ca-contracts.js.map +1 -1
  26. package/dist/elements/lists/ca-journal-table.d.ts +3 -0
  27. package/dist/elements/lists/ca-journal-table.d.ts.map +1 -1
  28. package/dist/elements/lists/ca-journal-table.js +18 -5
  29. package/dist/elements/lists/ca-journal-table.js.map +1 -1
  30. package/dist/elements/lists/ca-panel-body.d.ts +3 -0
  31. package/dist/elements/lists/ca-panel-body.d.ts.map +1 -1
  32. package/dist/elements/lists/ca-panel-body.js +11 -1
  33. package/dist/elements/lists/ca-panel-body.js.map +1 -1
  34. package/dist/elements/lists/ca-task-table.d.ts +2 -0
  35. package/dist/elements/lists/ca-task-table.d.ts.map +1 -1
  36. package/dist/elements/lists/ca-task-table.js +5 -1
  37. package/dist/elements/lists/ca-task-table.js.map +1 -1
  38. package/dist/elements/overview/ca-overview.d.ts.map +1 -1
  39. package/dist/elements/overview/ca-overview.js +3 -1
  40. package/dist/elements/overview/ca-overview.js.map +1 -1
  41. package/dist/elements/overview/ca-pipeline-graph.d.ts +10 -1
  42. package/dist/elements/overview/ca-pipeline-graph.d.ts.map +1 -1
  43. package/dist/elements/overview/ca-pipeline-graph.js +57 -17
  44. package/dist/elements/overview/ca-pipeline-graph.js.map +1 -1
  45. package/dist/elements/overview/pipeline-graph/inspector.js +2 -1
  46. package/dist/elements/overview/pipeline-graph/inspector.js.map +1 -1
  47. package/dist/elements/overview/pipeline-graph/strings.d.ts.map +1 -1
  48. package/dist/elements/overview/pipeline-graph/strings.js +15 -1
  49. package/dist/elements/overview/pipeline-graph/strings.js.map +1 -1
  50. package/dist/words/clock-format.d.ts +3 -0
  51. package/dist/words/clock-format.d.ts.map +1 -0
  52. package/dist/words/clock-format.js +19 -0
  53. package/dist/words/clock-format.js.map +1 -0
  54. package/dist/words/format.d.ts.map +1 -1
  55. package/dist/words/format.js +2 -17
  56. package/dist/words/format.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/adapters/filters.ts +4 -1
  59. package/src/adapters/graph/compound.ts +9 -1
  60. package/src/adapters/graph/layer.ts +4 -1
  61. package/src/adapters/graph/matrix.ts +38 -7
  62. package/src/adapters/lanes.ts +29 -11
  63. package/src/adapters/panel-body.ts +13 -3
  64. package/src/elements/lists/ca-contracts.ts +29 -11
  65. package/src/elements/lists/ca-journal-table.ts +19 -5
  66. package/src/elements/lists/ca-panel-body.ts +11 -1
  67. package/src/elements/lists/ca-task-table.ts +6 -1
  68. package/src/elements/overview/ca-overview.ts +3 -1
  69. package/src/elements/overview/ca-pipeline-graph.ts +54 -15
  70. package/src/elements/overview/pipeline-graph/inspector.ts +2 -1
  71. package/src/elements/overview/pipeline-graph/strings.ts +15 -1
  72. package/src/words/clock-format.ts +20 -0
  73. package/src/words/format.ts +2 -18
@@ -1,7 +1,7 @@
1
1
  // A loop's passes: a loop box unrolled into units by (node, pass).
2
2
  import type { Plan, TaskState } from '@contraagent/api';
3
3
  import { arrange, type GraphOrder } from '@contraagent/elements/graph';
4
- import { cmpPasses, passesAt, push, type FlowTask } from './flow-index.js';
4
+ import { passesAt, push, type FlowTask } from './flow-index.js';
5
5
  import { arranged, flowMarks, project, shapeOf, taskMark, type Draft, type Projection } from './project.js';
6
6
  import { SEVERITY, worstOf } from './states.js';
7
7
  import { LOOP_SEGMENT, type LayerMark, type GraphNode, type LoopBox } from './types.js';
@@ -62,10 +62,39 @@ export function passMatrix(plan: Plan, path: readonly string[], box: string, pre
62
62
  const rel = (t: FlowTask) => passesAt(t, d).slice(i0);
63
63
  const leaves = (x: LoopBox): string[] => x.layers.flat().flatMap((id) => (byBox.has(id) ? leaves(byBox.get(id) as LoopBox) : [id]));
64
64
  const tasksOf = (x: LoopBox): FlowTask[] => leaves(x).flatMap((id) => drafts.get(id)?.tasks ?? []);
65
- const same = (a: readonly number[], p: readonly number[]) => a.length === p.length && cmpPasses(a, p) === 0;
66
- const build = (x: LoopBox, prefix: number[]): ColTree[] => {
65
+ // Two pass lists are the same (`cmpPasses`, of one length) exactly when their JSON is.
66
+ const passKey = (v: readonly number[]) => JSON.stringify(v);
67
+ // Each node's tasks by their passes, in task order.
68
+ const byPasses = new Map<string, Map<string, FlowTask[]>>();
69
+ const tasksAt = (id: string, at: readonly number[]): readonly FlowTask[] => {
70
+ let m = byPasses.get(id);
71
+ if (!m) {
72
+ m = new Map();
73
+ for (const t of drafts.get(id)?.tasks ?? []) push(m, passKey(rel(t)), t);
74
+ byPasses.set(id, m);
75
+ }
76
+ return m.get(passKey(at)) ?? [];
77
+ };
78
+ // The passes the tasks of a box ran after each list of earlier passes: by box, by the length of that list.
79
+ const nextPasses = new Map<LoopBox, Map<number, Map<string, Set<number>>>>();
80
+ const passesAfter = (x: LoopBox, prefix: readonly number[]): number[] => {
67
81
  const k = prefix.length;
68
- const passes = [...new Set(tasksOf(x).map(rel).filter((v) => v.length > k && cmpPasses(v.slice(0, k), prefix) === 0).map((v) => v[k] as number))].sort((a, c) => a - c);
82
+ let byK = nextPasses.get(x);
83
+ if (!byK) nextPasses.set(x, (byK = new Map<number, Map<string, Set<number>>>()));
84
+ let byPrefix = byK.get(k);
85
+ if (!byPrefix) {
86
+ byPrefix = new Map();
87
+ for (const v of tasksOf(x).map(rel)) {
88
+ if (v.length <= k) continue;
89
+ const key = passKey(v.slice(0, k));
90
+ (byPrefix.get(key) ?? byPrefix.set(key, new Set()).get(key))?.add(v[k] as number);
91
+ }
92
+ byK.set(k, byPrefix);
93
+ }
94
+ return [...(byPrefix.get(passKey(prefix)) ?? [])].sort((a, c) => a - c);
95
+ };
96
+ const build = (x: LoopBox, prefix: number[]): ColTree[] => {
97
+ const passes = passesAfter(x, prefix);
69
98
  return passes.flatMap((p): ColTree[] => {
70
99
  const at = [...prefix, p];
71
100
  const kids = x.layers.flat().flatMap((id): ColTree[] => {
@@ -75,7 +104,7 @@ export function passMatrix(plan: Plan, path: readonly string[], box: string, pre
75
104
  return sub.length ? [{ type: 'loop', box: inner, kids: sub }] : [];
76
105
  }
77
106
  const node = byNode.get(id);
78
- return node && drafts.get(id)?.tasks.some((t) => same(rel(t), at)) ? [{ type: 'node', node, passes: at }] : [];
107
+ return node && tasksAt(id, at).length ? [{ type: 'node', node, passes: at }] : [];
79
108
  });
80
109
  return kids.length ? [{ type: 'pass', box: x, pass: p, kids }] : [];
81
110
  });
@@ -103,7 +132,7 @@ export function passMatrix(plan: Plan, path: readonly string[], box: string, pre
103
132
  const rowUnits = [...graph.units.filter((u) => unitsIn.has(u)), ...(unitsIn.has('') ? [''] : [])];
104
133
  const cellTasks = new Map<string, FlowTask[]>(); // `${column}\0${unit}`
105
134
  columns.forEach((c, ci) => {
106
- for (const t of drafts.get(c.node.id)?.tasks ?? []) if (same(rel(t), c.passes)) push(cellTasks, `${ci}\u0000${t.n.unit || ''}`, t);
135
+ for (const t of tasksAt(c.node.id, c.passes)) push(cellTasks, `${ci}\u0000${t.n.unit || ''}`, t);
107
136
  });
108
137
  const markOf = (c: MatrixColumn, ts: readonly FlowTask[], unit: string): LayerMark | null => {
109
138
  const x = drafts.get(c.node.id) as Draft;
@@ -111,12 +140,14 @@ export function passMatrix(plan: Plan, path: readonly string[], box: string, pre
111
140
  const m = flowMarks(ts, d, x.name, ix.calls);
112
141
  return unit ? (m.byUnit.get(unit) ?? m.single[0] ?? null) : (m.single[0] ?? null);
113
142
  };
143
+ const mineOf = new Map<string, FlowTask[]>();
144
+ for (const t of mine) push(mineOf, t.n.unit || '', t);
114
145
  const rows = rowUnits.map((unit): MatrixRow => {
115
146
  const cells = columns.map((c, ci) => {
116
147
  const ts = cellTasks.get(`${ci}\u0000${unit}`);
117
148
  return ts?.length ? markOf(c, ts, unit) : null;
118
149
  });
119
- const passes = new Set(mine.filter((t) => (t.n.unit || '') === unit).map((t) => passesAt(t, d).slice(0, i0 + 1).join('.'))).size;
150
+ const passes = new Set((mineOf.get(unit) ?? []).map((t) => passesAt(t, d).slice(0, i0 + 1).join('.'))).size;
120
151
  return { unit, passes, state: worstOf(cells.filter((m): m is LayerMark => m !== null).map((m) => m.state)), cells };
121
152
  });
122
153
  return { box: b, columns, head, rows };
@@ -5,6 +5,7 @@ import { isLaunched, isPython, runRelative, type JournalRow, type TaskOutcome }
5
5
  import type { Lane, LaneBar, LaneStyle } from '@contraagent/elements/lanes';
6
6
  import { msg, str } from '@lit/localize';
7
7
  import { getLocale } from '../core/i18n.js';
8
+ import { clockFormat } from '../words/clock-format.js';
8
9
  import { facts, fmtDur, kindUnit } from '../words/format.js';
9
10
  import { modelSwitch, notStartedCause, stopLabel } from '../words/index.js';
10
11
 
@@ -28,7 +29,8 @@ export interface TimelineLanes {
28
29
  omitted: number;
29
30
  }
30
31
 
31
- const hhmmss = (t: number) => new Date(t).toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
32
+ // The same text as `toLocaleTimeString` with these options, from a format made once.
33
+ const hhmmss = (t: number) => clockFormat(getLocale(), false).format(new Date(t));
32
34
 
33
35
  /**
34
36
  * The bars' looks and the legend: who ran it is an identity cue (Python
@@ -67,6 +69,28 @@ function rowLabel(e: JournalRow, end: number, outcomes: ReadonlyMap<string, Task
67
69
  .join('\n');
68
70
  }
69
71
 
72
+ type FinishedBar = { lane: string; bar: LaneBar & { end: number } };
73
+
74
+ /** The finished bars last made for a journal, and what else they were made from. */
75
+ const made = new WeakMap<readonly JournalRow[], { outcomes: ReadonlyMap<string, TaskOutcome>; run: string; locale: string; bars: FinishedBar[] }>();
76
+
77
+ /** Each finished row's bar, on its lane: made again only when the journal, the outcomes, the run or the language changed. */
78
+ function finishedBars(journal: readonly JournalRow[], outcomes: ReadonlyMap<string, TaskOutcome>, run: string): readonly FinishedBar[] {
79
+ const locale = getLocale();
80
+ const had = made.get(journal);
81
+ if (had && had.outcomes === outcomes && had.run === run && had.locale === locale) return had.bars;
82
+ const bars: FinishedBar[] = [];
83
+ journal.forEach((e, i) => {
84
+ if (!e.ts) return;
85
+ const end = Date.parse(e.ts);
86
+ if (Number.isNaN(end)) return;
87
+ const style: LaneStyleKey = !isLaunched(e) ? 'idle' : !e.ok ? 'bad' : isPython(e) ? 'py' : 'llm';
88
+ bars.push({ lane: e.kind || '?', bar: { id: `j${i}`, style, start: end - (e.wall_s || 0) * 1000, end, label: rowLabel(e, end, outcomes, run) } });
89
+ });
90
+ made.set(journal, { outcomes, run, locale, bars });
91
+ return bars;
92
+ }
93
+
70
94
  /**
71
95
  * Every finished task as a bar on its kind's lane, in order of first start,
72
96
  * and the running ones (`end` null); finished tasks that ended more than six
@@ -78,17 +102,11 @@ export function lanesFromJournal(
78
102
  outcomes: ReadonlyMap<string, TaskOutcome>,
79
103
  run: string,
80
104
  ): TimelineLanes | null {
81
- /** Each finished row's bar, on its lane. */
82
- const bars: { lane: string; bar: LaneBar & { end: number } }[] = [];
83
- journal.forEach((e, i) => {
84
- if (!e.ts) return;
85
- const end = Date.parse(e.ts);
86
- if (Number.isNaN(end)) return;
87
- const style: LaneStyleKey = !isLaunched(e) ? 'idle' : !e.ok ? 'bad' : isPython(e) ? 'py' : 'llm';
88
- bars.push({ lane: e.kind || '?', bar: { id: `j${i}`, style, start: end - (e.wall_s || 0) * 1000, end, label: rowLabel(e, end, outcomes, run) } });
89
- });
105
+ const bars = finishedBars(journal, outcomes, run);
90
106
  if (!bars.length && !live.length) return null;
91
- const newest = Math.max(...bars.map((x) => x.bar.end), ...live.map((a) => a.t0));
107
+ let newest = -Infinity;
108
+ for (const x of bars) newest = Math.max(newest, x.bar.end);
109
+ for (const a of live) newest = Math.max(newest, a.t0);
92
110
  const vis: { lane: string; bar: LaneBar }[] = bars.filter((x) => x.bar.end >= newest - TIMELINE_WINDOW_MS);
93
111
  const omitted = bars.length - vis.length;
94
112
  live.forEach((a, i) => vis.push({ lane: a.kind, bar: { id: `live${i}`, style: 'run', start: a.t0, end: null, label: `${a.kind}\n${msg('running')}` } }));
@@ -19,15 +19,25 @@ export const rowTone = (r: BodyRow): Tone | undefined => (typeof r._tone === 'st
19
19
  /** The formats of numbers: their columns are right-aligned. */
20
20
  const NUMERIC = new Set(['usd', 'pct', 'duration', 'tokens', 'number']);
21
21
 
22
- /** A cell as the text it is ordered by. */
23
- const cellKey = (v: unknown): string => (typeof v === 'string' ? v : JSON.stringify(v));
22
+ /** A cell as the text it is ordered by; an object's is written once. */
23
+ const objectKeys = new WeakMap<object, string>();
24
+ const cellKey = (v: unknown): string => {
25
+ if (typeof v === 'string') return v;
26
+ if (typeof v !== 'object' || v === null) return JSON.stringify(v);
27
+ let k = objectKeys.get(v);
28
+ if (k === undefined) objectKeys.set(v, (k = JSON.stringify(v)));
29
+ return k;
30
+ };
31
+
32
+ /** The order `localeCompare` gives, made once. */
33
+ const text = new Intl.Collator();
24
34
 
25
35
  /** The order of two cells: numbers by value, then text; empty cells last. */
26
36
  function compareCells(a: unknown, b: unknown): number {
27
37
  const empty = (v: unknown) => v === null || v === undefined || v === '';
28
38
  if (empty(a) || empty(b)) return empty(a) === empty(b) ? 0 : empty(a) ? 1 : -1;
29
39
  if (typeof a === 'number' && typeof b === 'number') return a - b;
30
- return cellKey(a).localeCompare(cellKey(b));
40
+ return text.compare(cellKey(a), cellKey(b));
31
41
  }
32
42
 
33
43
  /** `v` as a URL a cell may lead to: http or https only; null for anything else. */
@@ -29,6 +29,9 @@ export const taskName = (c: { key: string; kind?: string; unit?: string }): stri
29
29
  /** Problems first (the order of `CT_ORDER`), then by task. */
30
30
  const rank = (s: ContractStatus | null | undefined) => (s ? CT_ORDER.indexOf(s) : CT_ORDER.length);
31
31
 
32
+ /** The tasks' keys in the order `localeCompare` puts them, made once. */
33
+ const byKey = new Intl.Collator();
34
+
32
35
  /**
33
36
  * The files each task must write (`OutputContract`) and read (`InputContract`),
34
37
  * problems first, each with the chip of its status, then the passed ones under
@@ -77,6 +80,10 @@ export class CaContracts extends LitElement {
77
80
  /** The run's plan: with the journal, what happened to the task that writes a missing input. Optional. */
78
81
  @property({ attribute: false }) plan: Plan | null = null;
79
82
  private outcomes: ReadonlyMap<string, TaskOutcome> = new Map();
83
+ /** The outputs' rows sorted, each output's writer, and each task's last journal row: for the objects they were made from. */
84
+ private sorted: { outputs: Outputs | null; outs: (readonly [ContractRecord, ContractSpec])[] } | null = null;
85
+ private writers: { outputs: Outputs | null; by: Map<string, readonly [ContractRecord, ContractSpec]> } | null = null;
86
+ private lastRows: { journal: JournalRow[]; by: Map<string, JournalRow> } | null = null;
80
87
 
81
88
  /** Which contracts the reader opened, kept across Back and reload. */
82
89
  private readonly open = new RememberedSet('contracts:open', this);
@@ -87,9 +94,13 @@ export class CaContracts extends LitElement {
87
94
  if (!cs.length) {
88
95
  return html`<div class="blank"><span class="ic" aria-hidden="true">▣</span><b>${facts(msg('No checked files'), msg('no task declares an OutputContract or an InputContract'))}</b></div>`;
89
96
  }
90
- const outs = cs
91
- .flatMap((c) => (c.outputs ?? []).map((o) => [c, o] as const))
92
- .sort(([a, x], [b, y]) => rank(x.status) - rank(y.status) || a.key.localeCompare(b.key));
97
+ if (this.sorted?.outputs !== this.outputs) {
98
+ const outs = cs
99
+ .flatMap((c) => (c.outputs ?? []).map((o) => [c, o] as const))
100
+ .sort(([a, x], [b, y]) => rank(x.status) - rank(y.status) || byKey.compare(a.key, b.key));
101
+ this.sorted = { outputs: this.outputs, outs };
102
+ }
103
+ const outs = this.sorted.outs;
93
104
  const ins = cs.flatMap((c) => (c.inputs ?? []).map((i) => [c, i] as const));
94
105
  const others = outs.filter(([, o]) => o.status !== 'valid');
95
106
  const passed = outs.filter(([, o]) => o.status === 'valid');
@@ -139,20 +150,27 @@ export class CaContracts extends LitElement {
139
150
 
140
151
  /** Who writes each output file: for naming the task a missing input should have come from. */
141
152
  private producerOf = (path: string): Producer | undefined => {
142
- for (const c of this.outputs?.contracts ?? []) {
143
- const o = (c.outputs ?? []).find((x) => x.path === path);
144
- if (o) return { name: taskName(c), written: !!o.file.exists, outcome: this.outcomes.get(c.key)?.outcome };
153
+ if (this.writers?.outputs !== this.outputs) {
154
+ // The first task that declares the path, and its first output of it.
155
+ const by = new Map<string, readonly [ContractRecord, ContractSpec]>();
156
+ for (const c of this.outputs?.contracts ?? []) for (const o of c.outputs ?? []) if (!by.has(o.path)) by.set(o.path, [c, o]);
157
+ this.writers = { outputs: this.outputs, by };
145
158
  }
146
- return undefined;
159
+ const w = this.writers.by.get(path);
160
+ if (!w) return undefined;
161
+ const [c, o] = w;
162
+ return { name: taskName(c), written: !!o.file.exists, outcome: this.outcomes.get(c.key)?.outcome };
147
163
  };
148
164
 
149
165
  /** The reason of a task's last journal row (run-relative), when the journal was given. */
150
166
  private reasonOf(key: string): string {
151
- for (let i = this.journal.length - 1; i >= 0; i--) {
152
- const r = this.journal[i];
153
- if (r?.key === key) return runRelative(r.reason, this.run);
167
+ if (this.lastRows?.journal !== this.journal) {
168
+ const by = new Map<string, JournalRow>();
169
+ for (const r of this.journal) by.set(r.key, r);
170
+ this.lastRows = { journal: this.journal, by };
154
171
  }
155
- return '';
172
+ const r = this.lastRows.by.get(key);
173
+ return r ? runRelative(r.reason, this.run) : '';
156
174
  }
157
175
 
158
176
  private outRow(c: ContractRecord, o: ContractSpec) {
@@ -95,23 +95,37 @@ export class CaJournalTable extends LitElement {
95
95
  /** The run is a replay that also launched (`mixedReplay`): each attempt played from its recording is marked, and counted in a filter. */
96
96
  @property({ type: Boolean, attribute: 'replay-marks' }) replayMarks = false;
97
97
 
98
+ /** The counts of the rows, and the rows shown: for the rows (and filter) they were made from. */
99
+ private counts: { rows: JournalRow[]; replayMarks: boolean; fixups: number; retried: number; replayed: number; tally: ReturnType<typeof journalTally> } | null = null;
100
+ private shown: { rows: JournalRow[]; kind: string; stop: string; q: string; shown: JournalRow[] } | null = null;
101
+
98
102
  private open(rows: readonly JournalRow[], id: string): void {
99
103
  const e = rows.find((r) => rowId(r) === id);
100
104
  if (e) emit(this, 'ca-open-task', isPython(e) ? { key: e.key, view: 'output' } : { key: e.key });
101
105
  }
102
106
 
103
107
  override render() {
104
- const rows = filterJournal(this.rows, this.kind, this.stop, this.q).reverse();
105
- const fixups = this.rows.filter(hadFixup).length;
106
- const retried = retriedKeys(this.rows).size;
107
- const replayed = this.replayMarks ? this.rows.filter((r) => r.replayed).length : 0;
108
+ const s = this.shown;
109
+ const rows = s && s.rows === this.rows && s.kind === this.kind && s.stop === this.stop && s.q === this.q ? s.shown : filterJournal(this.rows, this.kind, this.stop, this.q).reverse();
110
+ this.shown = { rows: this.rows, kind: this.kind, stop: this.stop, q: this.q, shown: rows };
111
+ if (this.counts?.rows !== this.rows || this.counts.replayMarks !== this.replayMarks) {
112
+ this.counts = {
113
+ rows: this.rows,
114
+ replayMarks: this.replayMarks,
115
+ fixups: this.rows.filter(hadFixup).length,
116
+ retried: retriedKeys(this.rows).size,
117
+ replayed: this.replayMarks ? this.rows.filter((r) => r.replayed).length : 0,
118
+ tally: journalTally(this.rows),
119
+ };
120
+ }
121
+ const { fixups, retried, replayed, tally } = this.counts;
108
122
  const chip = (k: string, label: string, n: number, tip: string, tone: Tone = 'neutral') =>
109
123
  html`<button type="button" class="jchip ${this.stop === k ? 'on' : ''}" data-tone=${toneAttr(tone)} data-mark=${markOf(tone) ?? nothing}
110
124
  aria-pressed=${this.stop === k} title=${tip}
111
125
  @click=${() => emit(this, 'ca-filter', { stop: this.stop === k ? '' : k })}>${label} <b>${n}</b></button>`;
112
126
  const columns = journalColumns({ run: this.run, outcomes: deriveRun(this.rows, this.plan).outcomes, journal: this.rows, rows, extras: this.extras, replayMarks: this.replayMarks });
113
127
  return html`<div class="jtally">
114
- ${journalTally(this.rows).map(([k, c]) => chip(k, shortStop(k), c, k, stopTone(k)))}
128
+ ${tally.map(([k, c]) => chip(k, shortStop(k), c, k, stopTone(k)))}
115
129
  ${fixups ? chip('fixup', msg('attempts with corrections'), fixups, correctionTip()) : nothing}
116
130
  ${retried ? chip('retried', msg('tasks retried'), retried, retryTip()) : nothing}
117
131
  ${replayed ? chip('replayed', msg('attempts replayed'), replayed, replayedTip()) : nothing}
@@ -85,6 +85,8 @@ export class CaPanelBody extends LitElement {
85
85
 
86
86
  /** The order of each table's rows, by the table's place in the body. */
87
87
  @state() private sorts = new Map<string, SortState>();
88
+ /** Each table's rows as last sorted, for the body and the order they were sorted for. */
89
+ private sorted = new Map<string, { b: TableBody; sort: SortState; rows: BodyRow[] }>();
88
90
  /** The node the reader selected in each tree, by the tree's place in the body. */
89
91
  @state() private picks = new Map<string, string>();
90
92
 
@@ -191,12 +193,20 @@ export class CaPanelBody extends LitElement {
191
193
  }
192
194
  }
193
195
 
196
+ private sortedRows(at: string, b: TableBody, sort: SortState, make: () => BodyRow[]): BodyRow[] {
197
+ const had = this.sorted.get(at);
198
+ if (had && had.b === b && had.sort === sort) return had.rows;
199
+ const rows = make();
200
+ this.sorted.set(at, { b, sort, rows });
201
+ return rows;
202
+ }
203
+
194
204
  private table(b: TableBody, at: string): TemplateResult {
195
205
  const columns = bodyColumns(b);
196
206
  const rows = b.rows as BodyRow[];
197
207
  const keys = new Map(rows.map((r, i) => [r, String(i)]));
198
208
  const sort = this.sorts.get(at) ?? null;
199
- const shown = sort ? sortRows(rows, columns, sort) : rows;
209
+ const shown = sort ? this.sortedRows(at, b, sort, () => sortRows(rows, columns, sort)) : rows;
200
210
  return html`<cae-data-table
201
211
  .columns=${columns}
202
212
  .rows=${shown}
@@ -60,6 +60,9 @@ export class CaTaskTable extends LitElement {
60
60
  /** The run id: paths in a cause are shown relative to it. */
61
61
  @property() run = '';
62
62
 
63
+ /** The rows shown, for the rows, kind and text they were filtered and sorted from. */
64
+ private shown: { all: readonly TaskRow[]; kind: string; q: string; rows: TaskRow[] } | null = null;
65
+
63
66
  private open(rows: readonly TaskRow[], key: string): void {
64
67
  const t = rows.find((r) => r.key === key);
65
68
  // A Python task has no transcript, but its detail shows what it was given and what it left.
@@ -69,7 +72,9 @@ export class CaTaskTable extends LitElement {
69
72
  override render() {
70
73
  const d = this.plan ? deriveRun(this.journal, this.plan) : null;
71
74
  const all = d?.rows ?? [];
72
- const rows = sortTasks(filterTasks(all, this.kind, this.q));
75
+ const had = this.shown;
76
+ const rows = had && had.all === all && had.kind === this.kind && had.q === this.q ? had.rows : sortTasks(filterTasks(all, this.kind, this.q));
77
+ this.shown = { all, kind: this.kind, q: this.q, rows };
73
78
  if (!rows.length)
74
79
  return html`<div class="blank"><span class="ic" aria-hidden="true">≡</span>${all.length ? msg('No tasks match the filter') : msg('No tasks')}</div>`;
75
80
  const columns = taskColumns({ run: this.run, live: this.live, outcomes: d?.outcomes ?? new Map(), all, rows, journal: this.journal });
@@ -22,8 +22,10 @@ import {
22
22
  import { localized, msg, str } from '@lit/localize';
23
23
  import { LitElement, css, html, nothing } from 'lit';
24
24
  import { customElement, property } from 'lit/decorators.js';
25
+ import { guard } from 'lit/directives/guard.js';
25
26
  import { repeat } from 'lit/directives/repeat.js';
26
27
  import { emit } from '../../core/events.js';
28
+ import { getLocale } from '../../core/i18n.js';
27
29
  import { facts, kindUnit, localTime, sentence } from '../../words/format.js';
28
30
  import { notStartedCause, outcomeLabel, stateCount, stopLabel } from '../../words/index.js';
29
31
  import { base } from '@contraagent/elements/styles';
@@ -263,7 +265,7 @@ export class CaOverview extends LitElement {
263
265
  ${s.cursor
264
266
  ? html`<section class="panel enter">
265
267
  <h3 title=${msg('Stage and step per unit (CursorStore)')}>${msg('Resume cursor')}</h3>
266
- ${this.cursor()}
268
+ ${guard([s.cursor, getLocale()], () => this.cursor())}
267
269
  </section>`
268
270
  : nothing}
269
271
  </div>
@@ -273,7 +273,9 @@ export class CaPipelineGraph extends LitElement {
273
273
  this.prevOrder = this.orders.get(path.join('/'));
274
274
  }
275
275
  const locale = getLocale();
276
- const words = structure || changed.has('journal') || changed.has('live') || changed.has('forecast') || locale !== this.locale;
276
+ // The journal says only why a task did not start: with none, the words stay as they are.
277
+ const why = changed.has('journal') && this.plan.nodes.some((n) => n.state === 'blocked');
278
+ const words = structure || why || changed.has('live') || changed.has('forecast') || locale !== this.locale;
277
279
  if (words || changed.has('sort')) this.build(words, locale);
278
280
  }
279
281
 
@@ -470,19 +472,61 @@ export class CaPipelineGraph extends LitElement {
470
472
  return this.controls && this.tree.length && w >= OUTLINE_WIDE ? w - OUTLINE_COLUMN : w;
471
473
  }
472
474
 
473
- /** The place keys on the way to the layer shown (the functions of its path). */
475
+ /** The place keys on the way to the layer shown (the functions of its path), for the layer and the places they were found for. */
476
+ private current: { segments: string[]; stats: Map<string, PlaceStats>; keys: Set<string> } | null = null;
474
477
  private currentKeys(): Set<string> {
478
+ const c = this.current;
479
+ if (c && c.segments === this.segments && c.stats === this.flowStats) return c.keys;
475
480
  const { path } = splitLayer(this.segments);
476
481
  const out = new Set<string>();
477
482
  for (const s of this.flowStats.values()) {
478
483
  const d = s.layer.length;
479
484
  if (d < path.length && startsWith(path, s.layer) && path[d] === s.name) out.add(s.key);
480
485
  }
486
+ this.current = { segments: this.segments, stats: this.flowStats, keys: out };
481
487
  return out;
482
488
  }
483
489
 
490
+ /** The places the outline unfolds on its own (`expanded`), and what they were found from. */
491
+ private unfolded: { current: Set<string>; open: Set<string>; selected: string; want: Set<string>; holds: Map<OutlineItem, boolean> } | null = null;
492
+ private unfoldedKeys(): Set<string> {
493
+ const current = this.currentKeys();
494
+ const selected = this.selectedItem();
495
+ const u = this.unfolded;
496
+ if (u && u.current === current && u.open === this.shownOpen && u.selected === selected) return u.want;
497
+ const want = new Set([...current, ...this.shownOpen, selected]);
498
+ this.unfolded = { current, open: this.shownOpen, selected, want, holds: new Map() };
499
+ return want;
500
+ }
501
+
502
+ /** The outline's items by id, the first in the tree's order, for the tree they are of. */
503
+ private items: { tree: OutlineItem[]; byId: Map<string, OutlineItem> } | null = null;
504
+ private itemById(id: string): OutlineItem | undefined {
505
+ if (this.items?.tree !== this.tree) {
506
+ const byId = new Map<string, OutlineItem>();
507
+ const walk = (xs: readonly OutlineItem[]): void => {
508
+ for (const it of xs) {
509
+ if (!byId.has(it.id)) byId.set(it.id, it);
510
+ walk(it.kids);
511
+ }
512
+ };
513
+ walk(this.tree);
514
+ this.items = { tree: this.tree, byId };
515
+ }
516
+ return this.items.byId.get(id);
517
+ }
518
+
484
519
  /** The place (or loop item) the selection is, or is in: '' for none. */
520
+ private selectedOf: { sel: string; graph: LayerGraph; item: string } | null = null;
485
521
  private selectedItem(): string {
522
+ const had = this.selectedOf;
523
+ if (had && had.sel === this.sel && had.graph === this.graph) return had.item;
524
+ const item = this.findSelectedItem();
525
+ this.selectedOf = { sel: this.sel, graph: this.graph, item };
526
+ return item;
527
+ }
528
+
529
+ private findSelectedItem(): string {
486
530
  const sel = this.sel;
487
531
  if (!sel) return '';
488
532
  if (sel.startsWith(FLOW_BOX)) return sel.slice(FLOW_BOX.length);
@@ -527,9 +571,14 @@ export class CaPipelineGraph extends LitElement {
527
571
  const own = el.folds.get(id);
528
572
  if (own !== undefined) return own;
529
573
  // Unfolded by default: the top items, and those on the way to the layer shown, to what is open and to the selection.
530
- const want = new Set([...el.currentKeys(), ...el.shownOpen, el.selectedItem()]);
531
- const has = (it: OutlineItem): boolean => want.has(it.id) || it.kids.some(has);
532
- const it = findItem(el.tree, id);
574
+ const want = el.unfoldedKeys();
575
+ const holds = el.unfolded?.holds ?? new Map<OutlineItem, boolean>();
576
+ const has = (it: OutlineItem): boolean => {
577
+ let h = holds.get(it);
578
+ if (h === undefined) holds.set(it, (h = want.has(it.id) || it.kids.some(has)));
579
+ return h;
580
+ };
581
+ const it = el.itemById(id);
533
582
  return !!it && (el.tree.includes(it) || has(it));
534
583
  },
535
584
  expand(id, on) {
@@ -621,16 +670,6 @@ function lightsOf(plan: Plan, links: TaskLinks, g: LayerGraph): (key: string) =>
621
670
  };
622
671
  }
623
672
 
624
- /** The outline item `id`, wherever it is in the tree. */
625
- function findItem(items: readonly OutlineItem[], id: string): OutlineItem | undefined {
626
- for (const it of items) {
627
- if (it.id === id) return it;
628
- const k = findItem(it.kids, id);
629
- if (k) return k;
630
- }
631
- return undefined;
632
- }
633
-
634
673
  declare global {
635
674
  interface HTMLElementTagNameMap {
636
675
  'ca-pipeline-graph': CaPipelineGraph;
@@ -87,7 +87,8 @@ function inspect(h: PipelineHost, s: Selected) {
87
87
  </dl>
88
88
  <div class="acts">${link(msg(str`Inside ${shortName(m.flow)}`), () => primary(h, s), true)}${clear}</div>`;
89
89
  }
90
- const rows = h.journal.filter((r) => m.keys.includes(r.key));
90
+ const keys = new Set(m.keys);
91
+ const rows = h.journal.filter((r) => keys.has(r.key));
91
92
  const ran = rows.filter(isLaunched);
92
93
  const costs = ran.map((r) => r.cost_usd).filter((x): x is number => typeof x === 'number');
93
94
  const wall = ran.reduce((a, r) => a + (r.wall_s ?? 0), 0);
@@ -2,6 +2,7 @@
2
2
  import type { LayeredGraphStrings } from '@contraagent/elements/graph';
3
3
  import type { MarkMatrixStrings } from '@contraagent/elements/matrix';
4
4
  import { msg, str } from '@lit/localize';
5
+ import { getLocale } from '../../../core/i18n.js';
5
6
  import { listText } from '../../../words/format.js';
6
7
  import { lineageWord } from '../../../words/lineage.js';
7
8
 
@@ -11,6 +12,17 @@ import { lineageWord } from '../../../words/lineage.js';
11
12
  * node always has its `progress`, so the words of a node's default count are not needed.
12
13
  */
13
14
  export function graphStrings(up: boolean, functions: boolean): Omit<LayeredGraphStrings, 'progress'> {
15
+ // The same words are the same object: the graph does not draw its nodes again for them.
16
+ const id = `${getLocale()}|${up}|${functions}`;
17
+ let words = made.get(id);
18
+ if (!words) made.set(id, (words = makeGraphStrings(up, functions)));
19
+ return words;
20
+ }
21
+
22
+ const made = new Map<string, Omit<LayeredGraphStrings, 'progress'>>();
23
+ let madeMatrix: { locale: string; words: MarkMatrixStrings } | null = null;
24
+
25
+ function makeGraphStrings(up: boolean, functions: boolean): Omit<LayeredGraphStrings, 'progress'> {
14
26
  const enter = msg('Enter: open');
15
27
  return {
16
28
  graph: msg('Graph', { id: 'view-graph', desc: 'Button: show the pipeline as a graph' }),
@@ -52,5 +64,7 @@ export function graphStrings(up: boolean, functions: boolean): Omit<LayeredGraph
52
64
 
53
65
  /** The words of a loop's passes. */
54
66
  export function matrixStrings(): MarkMatrixStrings {
55
- return { sortBy: msg('Sort units'), rowsHeader: msg('Unit') };
67
+ const locale = getLocale();
68
+ if (madeMatrix?.locale !== locale) madeMatrix = { locale, words: { sortBy: msg('Sort units'), rowsHeader: msg('Unit') } };
69
+ return madeMatrix.words;
56
70
  }
@@ -0,0 +1,20 @@
1
+ // The one time format of rows and chips (`localTime`), made once per language.
2
+
3
+ const formats = new Map<string, Intl.DateTimeFormat>();
4
+
5
+ /** The time of day `10:38:40`, or with the date `Oct 12, 10:38:40`, as `locale` writes it. */
6
+ export function clockFormat(locale: string, withDate: boolean): Intl.DateTimeFormat {
7
+ const id = `${locale}|${withDate}`;
8
+ let f = formats.get(id);
9
+ if (!f) {
10
+ f = new Intl.DateTimeFormat(locale, {
11
+ ...(withDate ? { month: 'short', day: 'numeric' } : {}),
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ second: '2-digit',
15
+ hourCycle: 'h23',
16
+ });
17
+ formats.set(id, f);
18
+ }
19
+ return f;
20
+ }
@@ -1,27 +1,11 @@
1
1
  // Durations, times and small text helpers shared by the views.
2
2
  import { msg, str } from '@lit/localize';
3
3
  import { getLocale } from '../core/i18n.js';
4
+ import { clockFormat } from './clock-format.js';
4
5
 
5
6
  // The formats that read the same in every language are the generic package's.
6
7
  export { esc, fmtDur } from '@contraagent/elements';
7
8
 
8
- const formats = new Map<string, Intl.DateTimeFormat>();
9
- function formatter(locale: string, withDate: boolean): Intl.DateTimeFormat {
10
- const id = `${locale}|${withDate}`;
11
- let f = formats.get(id);
12
- if (!f) {
13
- f = new Intl.DateTimeFormat(locale, {
14
- ...(withDate ? { month: 'short', day: 'numeric' } : {}),
15
- hour: '2-digit',
16
- minute: '2-digit',
17
- second: '2-digit',
18
- hourCycle: 'h23',
19
- });
20
- formats.set(id, f);
21
- }
22
- return f;
23
- }
24
-
25
9
  /**
26
10
  * An ISO time in the reader's time zone, as the UI locale writes it: `10:38:40`,
27
11
  * or with the date `Oct 12, 10:38:40` (the month as a word, so no reader takes
@@ -31,7 +15,7 @@ export function localTime(ts: string | null | undefined, withDate = false, local
31
15
  if (!ts) return '';
32
16
  const d = new Date(ts);
33
17
  if (Number.isNaN(d.getTime())) return ts;
34
- return formatter(locale, withDate).format(d);
18
+ return clockFormat(locale, withDate).format(d);
35
19
  }
36
20
 
37
21
  /** An ISO time as `just now`, `5m ago`, `3h ago`, `2d ago` (the run picker only; rows and chips show `localTime`). */