@gaia-ai/addon-gaia-ui 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/dist/src/Component/section-list/index.d.ts +37 -0
  3. package/dist/src/Component/section-list/index.js +60 -13
  4. package/dist/src/Component/stats-panel/index.d.ts +49 -0
  5. package/dist/src/Component/stats-panel/index.js +114 -0
  6. package/dist/src/Component/top-tabs/index.d.ts +12 -11
  7. package/dist/src/Component/top-tabs/index.js +20 -21
  8. package/dist/src/Entity/conductor/Data/conductors.d.ts +9 -3
  9. package/dist/src/Entity/conductor/Data/conductors.js +9 -3
  10. package/dist/src/Entity/project/Component/project-list/index.d.ts +8 -0
  11. package/dist/src/Entity/project/Component/project-list/index.js +7 -0
  12. package/dist/src/Entity/project/Data/projects.d.ts +6 -6
  13. package/dist/src/Entity/project/Data/projects.js +4 -4
  14. package/dist/src/Entity/run/Component/run-detail/index.d.ts +17 -0
  15. package/dist/src/Entity/run/Component/run-detail/index.js +145 -0
  16. package/dist/src/Entity/run/Data/runs.d.ts +37 -0
  17. package/dist/src/Entity/run/Data/runs.js +63 -0
  18. package/dist/src/Entity/statistics/Data/statistics.d.ts +87 -0
  19. package/dist/src/Entity/statistics/Data/statistics.js +144 -0
  20. package/dist/src/Entity/term/Data/terms.d.ts +9 -2
  21. package/dist/src/Entity/term/Data/terms.js +9 -3
  22. package/dist/src/Entity/ticket/Component/comment-item/index.d.ts +1 -1
  23. package/dist/src/Entity/ticket/Component/comment-item/index.js +18 -26
  24. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +14 -2
  25. package/dist/src/Entity/ticket/Component/create-form/index.js +57 -23
  26. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +2 -2
  27. package/dist/src/Entity/ticket/Component/edit-form/index.js +45 -47
  28. package/dist/src/Entity/ticket/Component/tab-bar/index.js +26 -5
  29. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +47 -6
  30. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +236 -15
  31. package/dist/src/Entity/ticket/Data/detail.d.ts +16 -0
  32. package/dist/src/Entity/ticket/Data/detail.js +17 -1
  33. package/dist/src/Entity/ticket/Data/parents.d.ts +27 -0
  34. package/dist/src/Entity/ticket/Data/parents.js +67 -0
  35. package/dist/src/Entity/ticket/Data/tickets.d.ts +15 -0
  36. package/dist/src/Entity/ticket/Data/tickets.js +14 -0
  37. package/dist/src/Entity/ticket/Form/create-data.d.ts +55 -0
  38. package/dist/src/Entity/ticket/Form/create-data.js +147 -18
  39. package/dist/src/Entity/ticket/Form/create.d.ts +62 -1
  40. package/dist/src/Entity/ticket/Form/create.js +41 -0
  41. package/dist/src/Entity/transport.d.ts +30 -0
  42. package/dist/src/Entity/transport.js +27 -5
  43. package/dist/src/Entity/user/Data/users.d.ts +12 -3
  44. package/dist/src/Entity/user/Data/users.js +12 -3
  45. package/dist/src/Form/field.d.ts +61 -1
  46. package/dist/src/Form/field.js +11 -0
  47. package/dist/src/Form/form.d.ts +14 -1
  48. package/dist/src/Form/form.js +57 -0
  49. package/dist/src/Form/keys.js +17 -0
  50. package/dist/src/Kernel/tui-kernel.d.ts +11 -0
  51. package/dist/src/Kernel/tui-kernel.js +1052 -198
  52. package/dist/src/Screen/projects.d.ts +1 -0
  53. package/dist/src/Screen/projects.js +1 -0
  54. package/dist/src/Widget/action.d.ts +3 -0
  55. package/dist/src/Widget/action.js +9 -0
  56. package/dist/src/Widget/autocomplete.d.ts +3 -0
  57. package/dist/src/Widget/autocomplete.js +80 -0
  58. package/dist/src/Widget/chrome.d.ts +38 -0
  59. package/dist/src/Widget/chrome.js +60 -0
  60. package/dist/src/Widget/index.d.ts +4 -0
  61. package/dist/src/Widget/index.js +12 -0
  62. package/dist/src/Widget/multiselect.d.ts +3 -0
  63. package/dist/src/Widget/multiselect.js +36 -0
  64. package/dist/src/Widget/option-source.d.ts +58 -0
  65. package/dist/src/Widget/option-source.js +98 -0
  66. package/dist/src/Widget/render.d.ts +42 -0
  67. package/dist/src/Widget/render.js +82 -0
  68. package/dist/src/Widget/select.d.ts +3 -0
  69. package/dist/src/Widget/select.js +26 -0
  70. package/dist/src/Widget/text.d.ts +3 -0
  71. package/dist/src/Widget/text.js +23 -0
  72. package/dist/src/Widget/types.d.ts +59 -0
  73. package/dist/src/Widget/types.js +21 -0
  74. package/dist/src/launcher.d.ts +4 -3
  75. package/dist/src/launcher.js +4 -4
  76. package/dist/src/lib/hit-test.d.ts +39 -20
  77. package/dist/src/lib/hit-test.js +31 -22
  78. package/dist/src/lib/width.d.ts +36 -0
  79. package/dist/src/lib/width.js +147 -0
  80. package/dist/src/plugin.js +2 -2
  81. package/dist/src/route.js +2 -2
  82. package/dist/src/types.d.ts +16 -2
  83. package/package.json +3 -3
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @gaia-ai/addon-gaia-ui
2
2
 
3
- GAIA TUI: interactive terminal UI (project dashboard, ticket list, ticket detail) as a dropsh `tui` renderer plugin.
3
+ GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.
4
4
 
5
5
  Part of the GAIA CLI. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all addons. Source: https://git.key-tec.de/keytec/gaia (gaia-cli/).
@@ -7,3 +7,40 @@ export declare function flattenSections(sections: Section[]): {
7
7
  lines: string[];
8
8
  boldRows: Set<number>;
9
9
  };
10
+ /**
11
+ * One collapsible entry: a caret header row plus the rows shown in each state.
12
+ *
13
+ * GAIA-255 D2: the Comments tab's collapse/expand interaction and the Overview
14
+ * tab's Description are ONE primitive rather than two renderers that happen to
15
+ * agree — AC-2 asks for "the same expand interaction the Comments tab offers",
16
+ * and structural sharing is the only form of that claim a test can hold.
17
+ */
18
+ export interface ExpandableEntry {
19
+ /** The header row's text, after the caret. */
20
+ header: string;
21
+ /** Rows shown while collapsed (indented like the expanded body). */
22
+ collapsed: string[];
23
+ /** Rows shown while expanded. */
24
+ expanded: string[];
25
+ }
26
+ /** The rendered rows of a list of expandable entries. */
27
+ export interface ExpandableView {
28
+ lines: string[];
29
+ boldRows: Set<number>;
30
+ /** `headerRows[i]` is the row index of entry i's header — the selection map. */
31
+ headerRows: number[];
32
+ selectable: number;
33
+ }
34
+ /**
35
+ * Flatten expandable entries: one caret header row per entry (`▸` collapsed,
36
+ * `▾` expanded) followed by that state's rows. `headerRows[i]` is entry i's
37
+ * header row, which is both the highlight target and the click map (the rows are
38
+ * NOT uniformly spaced — an expanded entry appends its body).
39
+ *
40
+ * Zero entries render `emptyMessage` and nothing selectable, so an empty list is
41
+ * an explicit statement rather than a blank screen.
42
+ */
43
+ export declare function flattenExpandable(entries: ExpandableEntry[], { expanded, emptyMessage, }: {
44
+ expanded?: ReadonlySet<number>;
45
+ emptyMessage: string;
46
+ }): ExpandableView;
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Append one body row, applying the MINIMAL MARKDOWN rule: an ATX heading
3
+ * (`#`…`######`) renders as bold with the markers stripped — the same rule a
4
+ * section heading follows, styled after fit(). Bullets and code fences stay raw
5
+ * by design (explicit v1 decision).
6
+ *
7
+ * Extracted so `flattenSections` and `flattenExpandable` cannot answer "what does
8
+ * a `## Context` line render as" differently: the Description block is rendered
9
+ * by BOTH (flat on the piped render, expandable on the Overview tab), and a rule
10
+ * living in only one of them would make the same body look different depending on
11
+ * which renderer reached it.
12
+ */
13
+ function pushRow(lines, boldRows, line) {
14
+ const md = /^(#{1,6})\s+(.*)$/.exec(line);
15
+ if (md) {
16
+ boldRows.add(lines.length);
17
+ lines.push(md[2] ?? '');
18
+ }
19
+ else {
20
+ lines.push(line);
21
+ }
22
+ }
1
23
  // Flatten `{heading, lines}` sections into plain rows plus the indices that are
2
24
  // bold (section headings + markdown ATX headings). Headings carry NO ANSI here —
3
25
  // the draw step applies bold after fit(), so sanitizeDisplay never sees (and
@@ -8,20 +30,45 @@ export function flattenSections(sections) {
8
30
  for (const section of sections) {
9
31
  boldRows.add(lines.length);
10
32
  lines.push(section.heading);
11
- for (const line of section.lines) {
12
- // Minimal markdown: an ATX heading (`#`…`######`) renders as bold with the
13
- // markers stripped — same rule as a section heading, styled after fit().
14
- // Bullets and code fences stay raw by design (explicit v1 decision).
15
- const md = /^(#{1,6})\s+(.*)$/.exec(line);
16
- if (md) {
17
- boldRows.add(lines.length);
18
- lines.push(md[2] ?? '');
19
- }
20
- else {
21
- lines.push(line);
22
- }
23
- }
33
+ for (const line of section.lines)
34
+ pushRow(lines, boldRows, line);
24
35
  lines.push('');
25
36
  }
26
37
  return { lines, boldRows };
27
38
  }
39
+ /**
40
+ * Flatten expandable entries: one caret header row per entry (`▸` collapsed,
41
+ * `▾` expanded) followed by that state's rows. `headerRows[i]` is entry i's
42
+ * header row, which is both the highlight target and the click map (the rows are
43
+ * NOT uniformly spaced — an expanded entry appends its body).
44
+ *
45
+ * Zero entries render `emptyMessage` and nothing selectable, so an empty list is
46
+ * an explicit statement rather than a blank screen.
47
+ */
48
+ export function flattenExpandable(entries, { expanded = new Set(), emptyMessage,
49
+ // GAIA-255 AC-11 review: `ReadonlySet` because the render path is handed ONE
50
+ // shared empty set for every not-yet-expanded tab (`expandedFor`) — growing
51
+ // it here would leak one tab's expansion into all the others. The renderer
52
+ // only ever calls `.has()`, so the narrowing costs it nothing.
53
+ }) {
54
+ if (!entries.length) {
55
+ return {
56
+ lines: [emptyMessage],
57
+ boldRows: new Set(),
58
+ headerRows: [],
59
+ selectable: 0,
60
+ };
61
+ }
62
+ const lines = [];
63
+ const boldRows = new Set();
64
+ const headerRows = [];
65
+ entries.forEach((entry, i) => {
66
+ headerRows.push(lines.length);
67
+ boldRows.add(lines.length);
68
+ lines.push(`${expanded.has(i) ? '▾' : '▸'} ${entry.header}`);
69
+ for (const line of expanded.has(i) ? entry.expanded : entry.collapsed) {
70
+ pushRow(lines, boldRows, line);
71
+ }
72
+ });
73
+ return { lines, boldRows, headerRows, selectable: entries.length };
74
+ }
@@ -0,0 +1,49 @@
1
+ /** A filled bar cell. U+2588 FULL BLOCK. */
2
+ export declare const BAR_FULL = "\u2588";
3
+ /** An empty bar cell. U+2591 LIGHT SHADE. */
4
+ export declare const BAR_EMPTY = "\u2591";
5
+ /**
6
+ * The bar's width in display columns.
7
+ *
8
+ * `█`/`░` are narrow per `lib/width-table.ts` — neither is in `WIDE_RANGES` — so
9
+ * the row geometry GAIA-241's hit testing depends on is unaffected. They are NOT
10
+ * ASCII, though, and both are East-Asian-AMBIGUOUS: a terminal configured
11
+ * ambiguous-as-wide (common in CJK setups) renders them two columns and shifts
12
+ * every number column at runtime while every test here stays green, because the
13
+ * width table deliberately treats Ambiguous as narrow (the frame's box-drawing
14
+ * characters depend on that). Accepted rather than mitigated — spec R9. If it
15
+ * ever bites, the fix is an ASCII bar glyph, never a width-table change.
16
+ */
17
+ export declare const BAR_WIDTH = 20;
18
+ /** One row of a block: a name and its counts, most-significant number last. */
19
+ export interface StatsPanelRow {
20
+ label: string;
21
+ /**
22
+ * The counts to print, in column order. The LAST one is what the bar
23
+ * measures — for a breakdown row that is the month, so a row whose day count
24
+ * is zero still shows the activity it actually had.
25
+ */
26
+ numbers: number[];
27
+ }
28
+ export interface StatsPanelOptions {
29
+ /**
30
+ * Column titles, printed as a header row above the numbers. Omitted for a
31
+ * single-number block, where the label already says which window it is.
32
+ *
33
+ * The mock in spec D11 hangs these titles off the block's HEADING line; they
34
+ * are their own row here because the heading belongs to `flattenSections`,
35
+ * which owns the bold-row set. Same information, one row lower, and the two
36
+ * components keep their boundary.
37
+ */
38
+ columns?: readonly string[] | undefined;
39
+ /** What to render instead of rows when there are none (spec R7). */
40
+ emptyMessage?: string | undefined;
41
+ }
42
+ /**
43
+ * One block's body rows: an optional column header, then one row per entry.
44
+ *
45
+ * Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
46
+ * wide-character label cannot shift the numbers out of alignment — the failure
47
+ * that makes a table of counts unreadable exactly when the data is interesting.
48
+ */
49
+ export declare function statsPanelRows(rows: readonly StatsPanelRow[], { columns, emptyMessage }?: StatsPanelOptions): string[];
@@ -0,0 +1,114 @@
1
+ import { pad } from '../../lib/format.js';
2
+ import { displayWidth } from '../../lib/width.js';
3
+ // GAIA-256 spec D11 — the Dashboard's `/usage`-style bar rows.
4
+ //
5
+ // A SHARED component (the third in `src/Component/`), and it earns that place by
6
+ // carrying no entity vocabulary at all: it takes `{ label, numbers }` and draws
7
+ // bars. Nothing here knows a ticket from a run from a project — which is exactly
8
+ // what G4 asserts, and why the Dashboard can render four different blocks from
9
+ // one function.
10
+ //
11
+ // It produces the ROWS OF ONE BLOCK. Stacking blocks under bold headings is
12
+ // `flattenSections`' job (`Component/section-list`), unchanged: this component
13
+ // only fills a block's body.
14
+ /** A filled bar cell. U+2588 FULL BLOCK. */
15
+ export const BAR_FULL = '█';
16
+ /** An empty bar cell. U+2591 LIGHT SHADE. */
17
+ export const BAR_EMPTY = '░';
18
+ /**
19
+ * The bar's width in display columns.
20
+ *
21
+ * `█`/`░` are narrow per `lib/width-table.ts` — neither is in `WIDE_RANGES` — so
22
+ * the row geometry GAIA-241's hit testing depends on is unaffected. They are NOT
23
+ * ASCII, though, and both are East-Asian-AMBIGUOUS: a terminal configured
24
+ * ambiguous-as-wide (common in CJK setups) renders them two columns and shifts
25
+ * every number column at runtime while every test here stays green, because the
26
+ * width table deliberately treats Ambiguous as narrow (the frame's box-drawing
27
+ * characters depend on that). Accepted rather than mitigated — spec R9. If it
28
+ * ever bites, the fix is an ASCII bar glyph, never a width-table change.
29
+ */
30
+ export const BAR_WIDTH = 20;
31
+ /**
32
+ * The label column, in display columns: 26 for the text plus {@link LABEL_GAP}.
33
+ *
34
+ * The text budget is wide enough for the longest label the Dashboard actually
35
+ * builds: a breakdown row suffixes its entity type (`gaia · tickets`), the
36
+ * unattributed bucket's `— unattributed · tickets` is 24 columns and a real
37
+ * project name reaches 26 (`gaia-e2e-project · tickets`). At 18 that suffix was
38
+ * cut to `— unattributed · t…`, which silently made the two entity types
39
+ * indistinguishable in exactly the row where attribution is already unclear.
40
+ */
41
+ const LABEL_WIDTH = 27;
42
+ /**
43
+ * Columns of the label field reserved as the gap before the bar.
44
+ *
45
+ * Load-bearing, not decoration. `pad` fills a label to exactly `LABEL_WIDTH`, so
46
+ * a label of precisely that width left NO separator and the row rendered
47
+ * `gaia-e2e-project · tickets████████` — a real project name, at exactly 26
48
+ * columns. A truncated label collided the same way, ending in `…` against the
49
+ * bar. Every fixture in the tests was shorter, which is why the suite was green
50
+ * while the screen was wrong; the gap is part of the geometry now so the label
51
+ * length cannot decide whether it exists.
52
+ *
53
+ * It is ADDED to the field rather than taken out of it: the 26 columns above are
54
+ * a content budget with a stated reason, and spending one of them on the gap
55
+ * would cut `gaia-e2e-project · tickets` to `gaia-e2e-project · ticke…` — losing
56
+ * exactly the entity-type suffix that tells the two rows of a project apart.
57
+ */
58
+ const LABEL_GAP = 1;
59
+ /** One number column, in display columns. */
60
+ const NUMBER_WIDTH = 8;
61
+ /** The indent every row of a block carries, matching the other body renderers. */
62
+ const INDENT = ' ';
63
+ /** `value` right-aligned to `width` DISPLAY columns — the number columns' rule. */
64
+ function padNumber(value, width) {
65
+ const gap = Math.max(0, width - displayWidth(value));
66
+ return ' '.repeat(gap) + value;
67
+ }
68
+ /**
69
+ * The bar for `value` within a group whose largest bar value is `max`.
70
+ *
71
+ * Scaled RELATIVE TO THE LARGEST VALUE IN ITS OWN GROUP, not to a limit. This is
72
+ * the one place the `/usage` analogy does not carry: `/usage` measures against a
73
+ * quota, and there is no quota here — so a full bar means "the biggest of these",
74
+ * never "100 %". An all-zero group has no maximum to scale by and renders empty
75
+ * rather than dividing by zero.
76
+ */
77
+ function bar(value, max) {
78
+ const filled = max > 0
79
+ ? Math.max(0, Math.min(BAR_WIDTH, Math.round((value / max) * BAR_WIDTH)))
80
+ : 0;
81
+ return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled);
82
+ }
83
+ /**
84
+ * One block's body rows: an optional column header, then one row per entry.
85
+ *
86
+ * Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
87
+ * wide-character label cannot shift the numbers out of alignment — the failure
88
+ * that makes a table of counts unreadable exactly when the data is interesting.
89
+ */
90
+ export function statsPanelRows(rows, { columns, emptyMessage } = {}) {
91
+ if (rows.length === 0) {
92
+ return emptyMessage === undefined ? [] : [`${INDENT}${emptyMessage}`];
93
+ }
94
+ const barValue = (row) => row.numbers.at(-1) ?? 0;
95
+ const max = Math.max(...rows.map(barValue));
96
+ const out = [];
97
+ if (columns && columns.length > 0) {
98
+ out.push(INDENT +
99
+ pad('', LABEL_WIDTH) +
100
+ ' '.repeat(BAR_WIDTH) +
101
+ columns.map((c) => padNumber(c, NUMBER_WIDTH)).join(''));
102
+ }
103
+ for (const row of rows) {
104
+ out.push(INDENT +
105
+ // Padded to the field width MINUS the gap, then the gap: the label
106
+ // column stays `LABEL_WIDTH` wide either way, so the header row above
107
+ // and every bar below still line up.
108
+ pad(row.label, LABEL_WIDTH - LABEL_GAP) +
109
+ ' '.repeat(LABEL_GAP) +
110
+ bar(barValue(row), max) +
111
+ row.numbers.map((n) => padNumber(String(n), NUMBER_WIDTH)).join(''));
112
+ }
113
+ return out;
114
+ }
@@ -5,11 +5,17 @@ export interface TopTab {
5
5
  }
6
6
  /** The root level: what there is before a project is chosen. */
7
7
  export declare const ROOT_TABS: readonly [{
8
+ readonly id: "dashboard";
9
+ readonly label: "Dashboard";
10
+ }, {
8
11
  readonly id: "projects";
9
12
  readonly label: "Projects";
10
13
  }];
11
14
  /** The project level: the tabs of an opened project. */
12
15
  export declare const PROJECT_TABS: readonly [{
16
+ readonly id: "dashboard";
17
+ readonly label: "Dashboard";
18
+ }, {
13
19
  readonly id: "tickets";
14
20
  readonly label: "Tickets";
15
21
  }, {
@@ -22,10 +28,16 @@ export declare const PROJECT_TABS: readonly [{
22
28
  /** The tab bars, keyed by level — the one place a level is named. */
23
29
  export declare const TAB_LEVELS: {
24
30
  readonly root: readonly [{
31
+ readonly id: "dashboard";
32
+ readonly label: "Dashboard";
33
+ }, {
25
34
  readonly id: "projects";
26
35
  readonly label: "Projects";
27
36
  }];
28
37
  readonly project: readonly [{
38
+ readonly id: "dashboard";
39
+ readonly label: "Dashboard";
40
+ }, {
29
41
  readonly id: "tickets";
30
42
  readonly label: "Tickets";
31
43
  }, {
@@ -75,17 +87,6 @@ export declare function topTabBar<L extends TabLevel>(level: L, activeId: TabIdO
75
87
  * not ask the compiler to correlate two independently narrowed properties.
76
88
  */
77
89
  export declare function tabBarFor(location: TabLocation): string;
78
- /**
79
- * Which level a tab id belongs to, as a whole {@link TabLocation} — or `null`
80
- * for an id no level declares.
81
- *
82
- * The cast is the one place the level→id pairing is asserted rather than
83
- * checked, and it is sound by construction: the id was just found **in that
84
- * level's own registry**, which is exactly what `TabLocation` requires.
85
- * TypeScript cannot see that through the `Object.keys` loop, so it is stated
86
- * here once, behind a checked lookup, instead of at every call site.
87
- */
88
- export declare function locateTab(tab: TopTabId): TabLocation | null;
89
90
  /** Where one tab sits inside the bar: `[start, end)` in display columns from the
90
91
  * bar's own first column. */
91
92
  export interface TopTabSpan<Id extends string = TopTabId> {
@@ -3,8 +3,9 @@ import { displayWidth } from '../../lib/width.js';
3
3
  // the top-level tabs are written. Navigation has THREE levels, and every screen
4
4
  // shows exactly ONE tab bar: the one of its own level.
5
5
  //
6
- // root dashboard, project-picker → Projects
7
- // project project, ticket-agents, project-runs Tickets · Ticket-Agents · Runs
6
+ // root dashboard, projects, project-picker Dashboard · Projects
7
+ // project dashboard, project, ticket-agents, Dashboard · Tickets ·
8
+ // project-runs Ticket-Agents · Runs
8
9
  // ticket ticket → the DETAIL tabs, drawn as the
9
10
  // header box's meta row, so no
10
11
  // top-level bar at all
@@ -14,6 +15,21 @@ import { displayWidth } from '../../lib/width.js';
14
15
  // (GAIA-192) — all three project-scoped, which is why they sit on the project
15
16
  // level and not on the root one.
16
17
  //
18
+ // GAIA-256 spec D2: `dashboard` is declared on BOTH levels, on purpose. There is
19
+ // ONE Dashboard screen — the root one and the project one are the same screen
20
+ // with its project filter unset or set, and its LEVEL follows that same bit, so
21
+ // the bar and the filter can never disagree: they read one value.
22
+ //
23
+ // That is why tab ids are no longer globally unique, and why nothing here
24
+ // requires them to be. The requirement was never a property of the model — every
25
+ // function in this file is already level-parameterised (`topTabBar(level, id)`,
26
+ // `topTabSpans(level, id)`, `tabBarFor(location)`, `TabIdOf<L>`). It was a
27
+ // property of ONE function, the level-blind `locateTab`, which walked the levels
28
+ // in key order and would now resolve the project level's `dashboard` to the ROOT
29
+ // location. `gotoTopTab` was its only caller, and it only ever moves within one
30
+ // level — the bar you Tab through or click IS the current level's bar — so it
31
+ // pairs the target with `here.level` directly and `locateTab` is deleted.
32
+ //
17
33
  // Only tabs that are actually navigable are listed. A planned screen (`Users` on
18
34
  // the root level, `Conductors` on the project level) arrives with its own ticket
19
35
  // and adds its entry here then — a tab drawn before its screen exists would
@@ -27,10 +43,12 @@ import { displayWidth } from '../../lib/width.js';
27
43
  // `string`, taking the compile-time check with it.
28
44
  /** The root level: what there is before a project is chosen. */
29
45
  export const ROOT_TABS = [
46
+ { id: 'dashboard', label: 'Dashboard' },
30
47
  { id: 'projects', label: 'Projects' },
31
48
  ];
32
49
  /** The project level: the tabs of an opened project. */
33
50
  export const PROJECT_TABS = [
51
+ { id: 'dashboard', label: 'Dashboard' },
34
52
  { id: 'tickets', label: 'Tickets' },
35
53
  { id: 'ticket-agents', label: 'Ticket-Agents' },
36
54
  { id: 'runs', label: 'Runs' },
@@ -65,25 +83,6 @@ export function topTabBar(level, activeId) {
65
83
  export function tabBarFor(location) {
66
84
  return renderBar(TAB_LEVELS[location.level], location.tab);
67
85
  }
68
- /**
69
- * Which level a tab id belongs to, as a whole {@link TabLocation} — or `null`
70
- * for an id no level declares.
71
- *
72
- * The cast is the one place the level→id pairing is asserted rather than
73
- * checked, and it is sound by construction: the id was just found **in that
74
- * level's own registry**, which is exactly what `TabLocation` requires.
75
- * TypeScript cannot see that through the `Object.keys` loop, so it is stated
76
- * here once, behind a checked lookup, instead of at every call site.
77
- */
78
- export function locateTab(tab) {
79
- for (const level of Object.keys(TAB_LEVELS)) {
80
- const tabs = TAB_LEVELS[level];
81
- if (tabs.some((t) => t.id === tab)) {
82
- return { level, tab };
83
- }
84
- }
85
- return null;
86
- }
87
86
  const renderSpans = (tabs, activeId) => {
88
87
  const spans = [];
89
88
  let start = 0;
@@ -1,5 +1,5 @@
1
1
  import type { DropshClient } from '../../../types.js';
2
- import { type OptionClients } from '../../transport.js';
2
+ import { type ListOutcome, type OptionClients } from '../../transport.js';
3
3
  /** One conductor of the control plane, as this renderer needs it. */
4
4
  export interface Conductor {
5
5
  id: string;
@@ -12,8 +12,14 @@ export interface Conductor {
12
12
  /** The conductor's project, `null` when unlinked. */
13
13
  projectId: string | null;
14
14
  }
15
- /** The conductor collection through one client, in server order. */
16
- export declare const listConductors: (client: DropshClient) => Promise<Conductor[]>;
15
+ /**
16
+ * The conductor collection through one client, in server order.
17
+ *
18
+ * Yields the OUTCOME rather than a swallowed array (GAIA-257 D4) — see the same
19
+ * note on `listUsers`. `listConductorsPreferringWrite` keeps the swallowing
20
+ * contract, because it already answers a failure by falling back.
21
+ */
22
+ export declare const listConductors: (client: DropshClient) => Promise<ListOutcome<Conductor>>;
17
23
  /**
18
24
  * The same collection read through the WRITE client where there is one — still just
19
25
  * reading, but the write scope is the one that decides what a write may reference,
@@ -1,4 +1,4 @@
1
- import { list, listPreferringWrite, } from '../../transport.js';
1
+ import { listOutcome, listPreferringWrite, } from '../../transport.js';
2
2
  const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
3
3
  const toConductor = (r) => ({
4
4
  id: r.id,
@@ -7,8 +7,14 @@ const toConductor = (r) => ({
7
7
  status: str(r.attr('status')),
8
8
  projectId: r.rel('project_id'),
9
9
  });
10
- /** The conductor collection through one client, in server order. */
11
- export const listConductors = (client) => list(client, 'gaia_conductor', toConductor);
10
+ /**
11
+ * The conductor collection through one client, in server order.
12
+ *
13
+ * Yields the OUTCOME rather than a swallowed array (GAIA-257 D4) — see the same
14
+ * note on `listUsers`. `listConductorsPreferringWrite` keeps the swallowing
15
+ * contract, because it already answers a failure by falling back.
16
+ */
17
+ export const listConductors = (client) => listOutcome(client, 'gaia_conductor', toConductor);
12
18
  /**
13
19
  * The same collection read through the WRITE client where there is one — still just
14
20
  * reading, but the write scope is the one that decides what a write may reference,
@@ -0,0 +1,8 @@
1
+ /** The minimum shape a project row needs to render. */
2
+ export interface ProjectRowInput {
3
+ project: string;
4
+ derivedStatus: string;
5
+ ageLabel: string;
6
+ }
7
+ /** One line per project: name, derived status, relative age. */
8
+ export declare function projectRows(projects: ProjectRowInput[]): string[];
@@ -0,0 +1,7 @@
1
+ import { pad } from '../../../../lib/format.js';
2
+ /** One line per project: name, derived status, relative age. */
3
+ export function projectRows(projects) {
4
+ if (projects.length === 0)
5
+ return ['No projects.'];
6
+ return projects.map((p) => `${pad(p.project, 24)} ${pad(p.derivedStatus, 14)} ${p.ageLabel}`);
7
+ }
@@ -1,21 +1,21 @@
1
1
  import type { DropshClient } from '../../../types.js';
2
2
  import { type ConductorStatus } from '../../conductor/Data/liveness.js';
3
- /** A derived dashboard row: one distinct project + its liveliest conductor. */
4
- export interface DashboardProject {
3
+ /** A derived project row: one distinct project + its liveliest conductor. */
4
+ export interface ProjectRow {
5
5
  project: string;
6
6
  derivedStatus: ConductorStatus;
7
7
  lastSeen: number | null;
8
8
  ageLabel: string;
9
9
  }
10
10
  /**
11
- * Resolve the dashboard's project rows straight from the control plane: list the
11
+ * Resolve the projects screen's rows straight from the control plane: list the
12
12
  * `gaia_conductor` entities (read is owner-gated, so this is already scoped to
13
13
  * the current user's conductors — no local registry needed), sideload their
14
14
  * `project_id`, group by project name, and report each project's liveliest
15
15
  * conductor (running > stale > offline). Everything is a Drupal entity read
16
- * through `services.client` (AC-5); the dashboard itself stays a screen/route.
16
+ * through `services.client` (AC-5); the screen itself stays a screen/route.
17
17
  */
18
- export declare function loadDashboardProjects({ client, nowSeconds, }: {
18
+ export declare function loadProjectRows({ client, nowSeconds, }: {
19
19
  client: DropshClient;
20
20
  nowSeconds: () => number;
21
- }): Promise<DashboardProject[]>;
21
+ }): Promise<ProjectRow[]>;
@@ -28,14 +28,14 @@ const STATUS_RANK = {
28
28
  'registry-only': 3,
29
29
  };
30
30
  /**
31
- * Resolve the dashboard's project rows straight from the control plane: list the
31
+ * Resolve the projects screen's rows straight from the control plane: list the
32
32
  * `gaia_conductor` entities (read is owner-gated, so this is already scoped to
33
33
  * the current user's conductors — no local registry needed), sideload their
34
34
  * `project_id`, group by project name, and report each project's liveliest
35
35
  * conductor (running > stale > offline). Everything is a Drupal entity read
36
- * through `services.client` (AC-5); the dashboard itself stays a screen/route.
36
+ * through `services.client` (AC-5); the screen itself stays a screen/route.
37
37
  */
38
- export async function loadDashboardProjects({ client, nowSeconds, }) {
38
+ export async function loadProjectRows({ client, nowSeconds, }) {
39
39
  const params = new DrupalJsonApiParams().addInclude(['project_id']);
40
40
  let doc;
41
41
  try {
@@ -57,7 +57,7 @@ export async function loadDashboardProjects({ client, nowSeconds, }) {
57
57
  const a = c.attributes ?? {};
58
58
  const projectId = singleRelId(c.relationships?.project_id);
59
59
  const project = projectId ? projectNameById.get(projectId) : undefined;
60
- // A conductor whose project cannot be resolved is not a dashboard row.
60
+ // A conductor whose project cannot be resolved is not a project row.
61
61
  if (!project)
62
62
  continue;
63
63
  const lastSeen = toEpochSeconds(a.last_seen ?? null);
@@ -0,0 +1,17 @@
1
+ import type { Section } from '../../../../Component/section-list/index.js';
2
+ import type { JsonApiResource, TicketDetailDocument } from '../../../../types.js';
3
+ /** The sideloaded `gaia_run` with this id, or undefined. */
4
+ export declare function runResource(document: TicketDetailDocument, runId: string | null | undefined): JsonApiResource | undefined;
5
+ /** `#<id> · <ticket> · <state>` — the run screen's header-box title row. */
6
+ export declare function runDetailTitle(document: TicketDetailDocument, runId: string | null | undefined): string;
7
+ /**
8
+ * The run's sections, in the order the screen draws them. An unresolvable run
9
+ * yields ONE section saying so, rather than an empty screen that looks like a
10
+ * failed render.
11
+ */
12
+ export declare function runDetailSections(document: TicketDetailDocument, runId: string | null | undefined): Section[];
13
+ /** The run block flattened — the screen's body, and the piped render (D7). */
14
+ export declare function runDetailLines(document: TicketDetailDocument, runId: string | null | undefined): {
15
+ lines: string[];
16
+ boldRows: Set<number>;
17
+ };