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

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 (98) hide show
  1. package/dist/src/Agent/Component/agent-list/index.d.ts +6 -0
  2. package/dist/src/Agent/Component/agent-list/index.js +14 -0
  3. package/dist/src/Agent/launch.d.ts +25 -0
  4. package/dist/src/Agent/launch.js +49 -0
  5. package/dist/src/Component/tab-bar/index.d.ts +11 -0
  6. package/dist/src/Component/tab-bar/index.js +21 -1
  7. package/dist/src/Component/ticket-detail/index.d.ts +8 -0
  8. package/dist/src/Component/ticket-detail/index.js +68 -4
  9. package/dist/src/Component/top-tabs/index.d.ts +117 -3
  10. package/dist/src/Component/top-tabs/index.js +124 -8
  11. package/dist/src/Entity/conductor/Data/conductors.d.ts +22 -0
  12. package/dist/src/Entity/conductor/Data/conductors.js +17 -0
  13. package/dist/src/Entity/conductor/Data/liveness.d.ts +10 -0
  14. package/dist/src/Entity/conductor/Data/liveness.js +25 -0
  15. package/dist/src/Entity/project/Component/dashboard-list/index.d.ts +8 -0
  16. package/dist/src/Entity/project/Component/dashboard-list/index.js +7 -0
  17. package/dist/src/Entity/project/Component/project-picker/index.d.ts +15 -0
  18. package/dist/src/Entity/project/Component/project-picker/index.js +42 -0
  19. package/dist/src/Entity/project/Data/projects.d.ts +21 -0
  20. package/dist/src/Entity/project/Data/projects.js +81 -0
  21. package/dist/src/Entity/project/Data/with-tickets.d.ts +20 -0
  22. package/dist/src/Entity/project/Data/with-tickets.js +36 -0
  23. package/dist/src/Entity/run/Component/run-table/index.d.ts +10 -0
  24. package/dist/src/Entity/run/Component/run-table/index.js +20 -0
  25. package/dist/src/Entity/run/Data/active-run.d.ts +15 -0
  26. package/dist/src/Entity/run/Data/active-run.js +23 -0
  27. package/dist/src/Entity/run/Data/runs.d.ts +35 -0
  28. package/dist/src/Entity/run/Data/runs.js +62 -0
  29. package/dist/src/Entity/run/Data/status.d.ts +12 -0
  30. package/dist/src/Entity/run/Data/status.js +40 -0
  31. package/dist/src/Entity/term/Data/terms.d.ts +9 -0
  32. package/dist/src/Entity/term/Data/terms.js +8 -0
  33. package/dist/src/Entity/ticket/Component/comment-item/index.d.ts +21 -0
  34. package/dist/src/Entity/ticket/Component/comment-item/index.js +39 -0
  35. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +3 -0
  36. package/dist/src/Entity/ticket/Component/create-form/index.js +26 -0
  37. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +28 -0
  38. package/dist/src/Entity/ticket/Component/edit-form/index.js +78 -0
  39. package/dist/src/Entity/ticket/Component/tab-bar/index.d.ts +20 -0
  40. package/dist/src/Entity/ticket/Component/tab-bar/index.js +44 -0
  41. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +65 -0
  42. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +240 -0
  43. package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +5 -0
  44. package/dist/src/Entity/ticket/Component/ticket-table/index.js +14 -0
  45. package/dist/src/Entity/ticket/Component/ticket-teaser/index.d.ts +7 -0
  46. package/dist/src/Entity/ticket/Component/ticket-teaser/index.js +76 -0
  47. package/dist/src/Entity/ticket/Data/detail.d.ts +12 -0
  48. package/dist/src/Entity/ticket/Data/detail.js +13 -0
  49. package/dist/src/Entity/ticket/Data/query.d.ts +20 -0
  50. package/dist/src/Entity/ticket/Data/query.js +66 -0
  51. package/dist/src/Entity/ticket/Data/tickets.d.ts +30 -0
  52. package/dist/src/Entity/ticket/Data/tickets.js +66 -0
  53. package/dist/src/Entity/ticket/Data/workflow.d.ts +13 -0
  54. package/dist/src/Entity/ticket/Data/workflow.js +26 -0
  55. package/dist/src/Entity/ticket/Data/write.d.ts +12 -0
  56. package/dist/src/Entity/ticket/Data/write.js +18 -0
  57. package/dist/src/Entity/ticket/Form/create-data.d.ts +9 -0
  58. package/dist/src/Entity/ticket/Form/create-data.js +49 -0
  59. package/dist/src/Entity/ticket/Form/create.d.ts +64 -0
  60. package/dist/src/Entity/ticket/Form/create.js +71 -0
  61. package/dist/src/Entity/ticket/Form/edit-data.d.ts +9 -0
  62. package/dist/src/Entity/ticket/Form/edit-data.js +38 -0
  63. package/dist/src/Entity/ticket/Form/edit.d.ts +65 -0
  64. package/dist/src/Entity/ticket/Form/edit.js +61 -0
  65. package/dist/src/Entity/transport.d.ts +20 -0
  66. package/dist/src/Entity/transport.js +27 -0
  67. package/dist/src/Entity/user/Data/users.d.ts +20 -0
  68. package/dist/src/Entity/user/Data/users.js +19 -0
  69. package/dist/src/Form/field.d.ts +39 -0
  70. package/dist/src/Form/field.js +16 -0
  71. package/dist/src/Form/form.d.ts +40 -0
  72. package/dist/src/Form/form.js +85 -0
  73. package/dist/src/Form/index.d.ts +3 -0
  74. package/dist/src/Form/index.js +5 -0
  75. package/dist/src/Form/keys.d.ts +3 -0
  76. package/dist/src/Form/keys.js +40 -0
  77. package/dist/src/Kernel/tui-kernel.js +591 -199
  78. package/dist/src/launcher.d.ts +29 -5
  79. package/dist/src/launcher.js +8 -0
  80. package/dist/src/lib/ansi.d.ts +15 -0
  81. package/dist/src/lib/ansi.js +53 -9
  82. package/dist/src/lib/format.d.ts +21 -0
  83. package/dist/src/lib/format.js +46 -4
  84. package/dist/src/lib/hit-test.d.ts +119 -0
  85. package/dist/src/lib/hit-test.js +129 -0
  86. package/dist/src/lib/jsonapi-error.d.ts +7 -0
  87. package/dist/src/lib/jsonapi-error.js +43 -0
  88. package/dist/src/lib/mouse.d.ts +35 -0
  89. package/dist/src/lib/mouse.js +89 -0
  90. package/dist/src/lib/width-table.d.ts +4 -0
  91. package/dist/src/lib/width-table.js +147 -0
  92. package/dist/src/lib/width.d.ts +25 -0
  93. package/dist/src/lib/width.js +130 -0
  94. package/dist/src/plugin.js +1 -1
  95. package/dist/src/route.d.ts +11 -0
  96. package/dist/src/route.js +215 -0
  97. package/dist/src/types.d.ts +38 -1
  98. package/package.json +2 -2
@@ -0,0 +1,6 @@
1
+ import type { HostedAgent } from '../../../types.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export declare function agentListRows(agents: HostedAgent[]): string[];
@@ -0,0 +1,14 @@
1
+ import { pad } from '../../../lib/format.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export function agentListRows(agents) {
7
+ const header = `${pad('AGENT', 32)} PROJECT`;
8
+ if (agents.length === 0)
9
+ return [header, 'No ticket agents.'];
10
+ return [
11
+ header,
12
+ ...agents.map((a) => `${pad(a.label || a.id, 32)} ${a.group}`),
13
+ ];
14
+ }
@@ -0,0 +1,25 @@
1
+ import type { CreateFormPayload } from '../Entity/ticket/Form/create.js';
2
+ import type { TuiAgentService } from '../types.js';
3
+ /** The launch intent passed to {@link TuiAgentService.launch}. */
4
+ export type CreateLaunch = Parameters<TuiAgentService['launch']>[0];
5
+ /**
6
+ * Build the `ticket:create` launch intent for a project. The prompt is a pure
7
+ * pointer to WORKFLOW.md (the pre-filled form is passed via
8
+ * `$GAIA_NEW_TICKET_INPUT`); `GAIA_ID` is never inherited (a create must not be
9
+ * bound to a claimed ticket).
10
+ */
11
+ export declare function buildCreateLaunch(opts: {
12
+ project: string;
13
+ cwd: string;
14
+ specPath?: string | undefined;
15
+ workflow?: string | undefined;
16
+ createPrompt?: string | undefined;
17
+ label?: string | undefined;
18
+ }): CreateLaunch;
19
+ /**
20
+ * A short human label for the agent list: the description's first line (capped to 40
21
+ * incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
22
+ * `buildCreateLaunch` takes, so it belongs to the module that builds the launch
23
+ * intent — moved from `Service/create-form.ts` unchanged.
24
+ */
25
+ export declare function shortName(payload: CreateFormPayload): string;
@@ -0,0 +1,49 @@
1
+ // Ticket-create launch request (AC-3). Pressing `n` starts an embedded agent
2
+ // that runs the GAIA `ticket:create` workflow. This module is pure: it builds
3
+ // the launch intent (prompt + env) the TUI hands to the injected agent host
4
+ // (`services.agents.launch`); the host (herdr) owns transport + layout. No
5
+ // node-pty / @xterm, no session object.
6
+ const CREATE_PROMPT = 'Read the repository\'s WORKFLOW.md and follow its "Create ticket" section to ' +
7
+ 'create a new GAIA ticket. Do not start work on the new ticket.';
8
+ /**
9
+ * Build the `ticket:create` launch intent for a project. The prompt is a pure
10
+ * pointer to WORKFLOW.md (the pre-filled form is passed via
11
+ * `$GAIA_NEW_TICKET_INPUT`); `GAIA_ID` is never inherited (a create must not be
12
+ * bound to a claimed ticket).
13
+ */
14
+ export function buildCreateLaunch(opts) {
15
+ const env = {
16
+ ...process.env,
17
+ TERM: 'xterm-256color',
18
+ GAIA_PROJECT: opts.project,
19
+ };
20
+ delete env.GAIA_ID;
21
+ if (opts.specPath)
22
+ env.GAIA_NEW_TICKET_INPUT = opts.specPath;
23
+ if (opts.workflow)
24
+ env.GAIA_WORKFLOW = opts.workflow;
25
+ return {
26
+ group: opts.project,
27
+ cwd: opts.cwd,
28
+ prompt: opts.createPrompt?.trim() || CREATE_PROMPT,
29
+ env,
30
+ isNew: true,
31
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
32
+ };
33
+ }
34
+ /**
35
+ * A short human label for the agent list: the description's first line (capped to 40
36
+ * incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
37
+ * `buildCreateLaunch` takes, so it belongs to the module that builds the launch
38
+ * intent — moved from `Service/create-form.ts` unchanged.
39
+ */
40
+ export function shortName(payload) {
41
+ const firstLine = payload.description
42
+ .split('\n')
43
+ .map((l) => l.trim())
44
+ .find((l) => l.length > 0);
45
+ if (firstLine) {
46
+ return firstLine.length > 40 ? `${firstLine.slice(0, 39)}…` : firstLine;
47
+ }
48
+ return `New ${payload.workflow?.label ?? 'ticket'}`;
49
+ }
@@ -7,3 +7,14 @@ export interface DetailTab {
7
7
  export declare const DETAIL_TABS: DetailTab[];
8
8
  /** The tab bar as a single row; the active tab is bracketed. */
9
9
  export declare function tabBar(activeIndex: number): string;
10
+ /** Where one detail tab sits inside the bar: `[start, end)` in display columns
11
+ * from the bar's own first column. */
12
+ export interface DetailTabSpan {
13
+ index: number;
14
+ start: number;
15
+ end: number;
16
+ }
17
+ /** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
18
+ * bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
19
+ * full argument. */
20
+ export declare function detailTabSpans(activeIndex: number): DetailTabSpan[];
@@ -1,3 +1,4 @@
1
+ import { displayWidth } from '../../lib/width.js';
1
2
  // The ticket-detail screen is split into tabs so only one tab's content draws at
2
3
  // a time (keeps the fixed-height window small) — Sub-tickets + Referenced share a
3
4
  // Related tab, Comments is its own tab with collapsible entries (owned by the
@@ -16,7 +17,26 @@ export const DETAIL_TABS = [
16
17
  },
17
18
  { id: 'runs', label: 'Runs', headings: ['Runs'] },
18
19
  ];
20
+ /** What joins two tabs in the rendered bar — part of neither tab's span. */
21
+ const SEPARATOR = ' ';
22
+ /** One tab's rendered segment: bracketed when active, space-padded otherwise. */
23
+ const segment = (tab, index, activeIndex) => index === activeIndex ? `[${tab.label}]` : ` ${tab.label} `;
19
24
  /** The tab bar as a single row; the active tab is bracketed. */
20
25
  export function tabBar(activeIndex) {
21
- return DETAIL_TABS.map((t, i) => i === activeIndex ? `[${t.label}]` : ` ${t.label} `).join(' ');
26
+ return DETAIL_TABS.map((t, i) => segment(t, i, activeIndex)).join(SEPARATOR);
27
+ }
28
+ /** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
29
+ * bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
30
+ * full argument. */
31
+ export function detailTabSpans(activeIndex) {
32
+ const spans = [];
33
+ let start = 0;
34
+ DETAIL_TABS.forEach((tab, index) => {
35
+ if (index > 0)
36
+ start += displayWidth(SEPARATOR);
37
+ const width = displayWidth(segment(tab, index, activeIndex));
38
+ spans.push({ index, start, end: start + width });
39
+ start += width;
40
+ });
41
+ return spans;
22
42
  }
@@ -1,6 +1,14 @@
1
1
  import type { TicketDetailDocument } from '../../types.js';
2
2
  import type { CommentItem } from '../comment-item/index.js';
3
3
  import type { Section } from '../section-list/index.js';
4
+ /**
5
+ * The `expandedComments` set a freshly opened ticket detail starts with: the
6
+ * newest comment expanded, every older one collapsed.
7
+ *
8
+ * It lives beside the sort on purpose — "index 0 is the newest" is one module's
9
+ * invariant, so the kernel never needs to know which index that is.
10
+ */
11
+ export declare function initialExpandedComments(document: TicketDetailDocument): Set<number>;
4
12
  /**
5
13
  * The ticket detail sections, each with an explicit empty state:
6
14
  * Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
@@ -19,8 +19,71 @@ function refCount(document, name) {
19
19
  const data = document.data?.relationships?.[name]?.data;
20
20
  return Array.isArray(data) ? data.length : data ? 1 : 0;
21
21
  }
22
+ /**
23
+ * Newest-first — the exact reversal of the server's total order
24
+ * (`CommentsItemList`: `created ASC, id ASC`), three keys applied in turn:
25
+ * `created` DESC, then `drupal_internal__id` DESC (the server's own monotonic
26
+ * key, so two comments written in the same second keep the server's relative
27
+ * order, reversed), then the resource `id` DESC.
28
+ *
29
+ * The third key exists to make the comparator TOTAL rather than leaning on
30
+ * `Array.prototype.sort` being stable in modern V8: the order must be
31
+ * *deterministic* — identical for the same comments in any input order, and
32
+ * defined even for a fixture or partial include that carries no
33
+ * `drupal_internal__id`. Stability would only preserve whatever order the
34
+ * include happened to arrive in.
35
+ *
36
+ * A missing or unparseable `created` sorts LAST (treated as the oldest), so a
37
+ * malformed row can never take the top slot and steal the default expansion.
38
+ */
39
+ function compareCommentsNewestFirst(a, b) {
40
+ const at = Date.parse(asString(a.attributes?.created) ?? '');
41
+ const bt = Date.parse(asString(b.attributes?.created) ?? '');
42
+ const aUnknown = Number.isNaN(at);
43
+ const bUnknown = Number.isNaN(bt);
44
+ if (aUnknown !== bUnknown)
45
+ return aUnknown ? 1 : -1;
46
+ if (!aUnknown && at !== bt)
47
+ return bt - at;
48
+ const ai = Number(a.attributes?.drupal_internal__id);
49
+ const bi = Number(b.attributes?.drupal_internal__id);
50
+ if (!Number.isNaN(ai) && !Number.isNaN(bi) && ai !== bi)
51
+ return bi - ai;
52
+ // Code-unit comparison, not localeCompare: a locale-sensitive collation would
53
+ // put the "deterministic" back in the hands of the runtime's locale data.
54
+ const aid = a.id ?? '';
55
+ const bid = b.id ?? '';
56
+ return aid === bid ? 0 : aid < bid ? 1 : -1;
57
+ }
58
+ /**
59
+ * The document's `gaia_comment` includes, NEWEST FIRST.
60
+ *
61
+ * This is the single function both renderings read — `detailSections`' Comments
62
+ * section (→ `detailLines`, the non-TTY render) and `commentItems` (→ the
63
+ * interactive Comments tab) — so newest-first is one line of code and the two
64
+ * orders structurally cannot drift.
65
+ *
66
+ * It sorts the copy `filter` already allocated and NEVER `document.included`
67
+ * itself: that array is mixed (comments *plus* sub-tickets, runs and referenced
68
+ * tickets) and shared with those consumers, so an in-place sort would be an
69
+ * invisible side effect on someone else's input.
70
+ */
22
71
  function commentResources(document) {
23
- return (document.included ?? []).filter((r) => r.type === 'gaia_comment--gaia_comment');
72
+ return (document.included ?? [])
73
+ .filter((r) => r.type === 'gaia_comment--gaia_comment')
74
+ .sort(compareCommentsNewestFirst);
75
+ }
76
+ /**
77
+ * The `expandedComments` set a freshly opened ticket detail starts with: the
78
+ * newest comment expanded, every older one collapsed.
79
+ *
80
+ * It lives beside the sort on purpose — "index 0 is the newest" is one module's
81
+ * invariant, so the kernel never needs to know which index that is.
82
+ */
83
+ export function initialExpandedComments(document) {
84
+ return commentResources(document).length
85
+ ? new Set([0])
86
+ : new Set();
24
87
  }
25
88
  /**
26
89
  * The ticket detail sections, each with an explicit empty state:
@@ -108,9 +171,10 @@ export function commentItems(document) {
108
171
  return { type, author, created, bodyLines, preview: bodyLines[0] ?? '' };
109
172
  });
110
173
  }
111
- // The whole ticket-detail document flattened (all sections). Reserved helper
112
- // currently unused by the interactive screen (which renders one tab at a time
113
- // via tabViewLines); kept as a tested convenience for a full-document render.
174
+ // The whole ticket-detail document flattened (all sections) the NON-TTY ticket
175
+ // render, printed verbatim by the kernel when stdout is not a terminal
176
+ // (`Kernel/tui-kernel.ts:147`), i.e. what `gaia ui GAIA-nnn | cat` shows. The
177
+ // interactive screen renders one tab at a time via tabViewLines instead.
114
178
  export function detailLines(detail) {
115
179
  return flattenSections(detailSections(detail ?? {}));
116
180
  }
@@ -3,6 +3,120 @@ export interface TopTab {
3
3
  id: string;
4
4
  label: string;
5
5
  }
6
- export declare const TOP_TABS: TopTab[];
7
- /** The top tab bar as a single row; the active tab is bracketed. */
8
- export declare function topTabBar(activeId: string): string;
6
+ /** The root level: what there is before a project is chosen. */
7
+ export declare const ROOT_TABS: readonly [{
8
+ readonly id: "projects";
9
+ readonly label: "Projects";
10
+ }];
11
+ /** The project level: the tabs of an opened project. */
12
+ export declare const PROJECT_TABS: readonly [{
13
+ readonly id: "tickets";
14
+ readonly label: "Tickets";
15
+ }, {
16
+ readonly id: "ticket-agents";
17
+ readonly label: "Ticket-Agents";
18
+ }, {
19
+ readonly id: "runs";
20
+ readonly label: "Runs";
21
+ }];
22
+ /** The tab bars, keyed by level — the one place a level is named. */
23
+ export declare const TAB_LEVELS: {
24
+ readonly root: readonly [{
25
+ readonly id: "projects";
26
+ readonly label: "Projects";
27
+ }];
28
+ readonly project: readonly [{
29
+ readonly id: "tickets";
30
+ readonly label: "Tickets";
31
+ }, {
32
+ readonly id: "ticket-agents";
33
+ readonly label: "Ticket-Agents";
34
+ }, {
35
+ readonly id: "runs";
36
+ readonly label: "Runs";
37
+ }];
38
+ };
39
+ /** The name of a level that carries a top-level tab bar. */
40
+ export type TabLevel = keyof typeof TAB_LEVELS;
41
+ /** The ids of one level's tabs, derived from {@link TAB_LEVELS}. */
42
+ export type TabIdOf<L extends TabLevel> = (typeof TAB_LEVELS)[L][number]['id'];
43
+ /**
44
+ * The id of any top-level tab, derived — never re-listed. Before GAIA-239 this
45
+ * union was hand-written in `tui-kernel.ts` alongside a third hardcoded copy of
46
+ * the same ids for the Tab cycle order; the three could drift silently.
47
+ */
48
+ export type TopTabId = {
49
+ [L in TabLevel]: TabIdOf<L>;
50
+ }[TabLevel];
51
+ /**
52
+ * Where a screen sits in the navigation: which bar it draws, and which tab of
53
+ * that bar is its own. A discriminated union over the levels, so a location can
54
+ * never pair one level's name with another level's tab id.
55
+ */
56
+ export type TabLocation = {
57
+ [L in TabLevel]: {
58
+ level: L;
59
+ tab: TabIdOf<L>;
60
+ };
61
+ }[TabLevel];
62
+ /**
63
+ * One level's tab bar as a single row; the active tab is bracketed.
64
+ *
65
+ * The level is part of the signature on purpose: it is what keeps the id checked
66
+ * at compile time now that there is more than one bar. `topTabBar('root', 'runs')`
67
+ * is a TS2345 naming the root level's valid ids — not a bar that silently
68
+ * brackets nothing.
69
+ */
70
+ export declare function topTabBar<L extends TabLevel>(level: L, activeId: TabIdOf<L>): string;
71
+ /**
72
+ * The same bar, for a caller holding a whole {@link TabLocation} rather than a
73
+ * literal level. The pairing was already checked when the location was built, so
74
+ * this needs no second check — and unlike `topTabBar(loc.level, loc.tab)` it does
75
+ * not ask the compiler to correlate two independently narrowed properties.
76
+ */
77
+ 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
+ /** Where one tab sits inside the bar: `[start, end)` in display columns from the
90
+ * bar's own first column. */
91
+ export interface TopTabSpan<Id extends string = TopTabId> {
92
+ id: Id;
93
+ start: number;
94
+ end: number;
95
+ }
96
+ /**
97
+ * One level's own hit map, built from the SAME `segment`/`SEPARATOR` that level's
98
+ * rendered bar is joined from — so the layout and the click mapping are two
99
+ * readings of one expression and cannot drift apart. That is the argument
100
+ * `lib/hit-test.ts` makes for the body rows, applied to a column axis.
101
+ *
102
+ * Offsets are display columns, which is the unit a mouse report carries. For the
103
+ * current ASCII labels that coincides with string offsets, which is what lets
104
+ * the test slice `topTabBar()` with a span and compare.
105
+ *
106
+ * GAIA-239: the LEVEL is part of the signature, exactly as it is for
107
+ * {@link topTabBar}, and for the same reason twice over. GAIA-241 shipped one
108
+ * `topTabSpans(activeId)` over the single flat bar; read level-blind it would
109
+ * publish the project bar's three spans on the root screen too, so a click at
110
+ * the column where `Ticket-Agents` used to sit would open a tab that screen does
111
+ * not draw — the very defect this ticket removes, arriving through the mouse.
112
+ * A map and a bar of two different levels is now a TS2345, not a silent
113
+ * mis-click.
114
+ */
115
+ export declare function topTabSpans<L extends TabLevel>(level: L, activeId: TabIdOf<L>): TopTabSpan<TabIdOf<L>>[];
116
+ /**
117
+ * The same hit map, for a caller holding a whole {@link TabLocation} — the
118
+ * companion of {@link tabBarFor}, and the pairing it needs was already checked
119
+ * when the location was built. Keeping the two in step is what makes "the bar you
120
+ * see is the bar you click" true by construction: both read `location`.
121
+ */
122
+ export declare function tabSpansFor(location: TabLocation): TopTabSpan[];
@@ -1,13 +1,129 @@
1
- // The top-level navigation tabs. `tickets` is the project/ticket drill-down;
2
- // `ticket-agents` is the managed create-agent list (GAIA-190); `runs` is the
3
- // project-scoped run overview (GAIA-192). `conductors` is a planned follow-up
4
- // (tracked separately) not wired yet.
5
- export const TOP_TABS = [
1
+ import { displayWidth } from '../../lib/width.js';
2
+ // GAIA-239: THE registry — the single place the set, the order and the labels of
3
+ // the top-level tabs are written. Navigation has THREE levels, and every screen
4
+ // shows exactly ONE tab bar: the one of its own level.
5
+ //
6
+ // root dashboard, project-picker → Projects
7
+ // project project, ticket-agents, project-runs → Tickets · Ticket-Agents · Runs
8
+ // ticket ticket → the DETAIL tabs, drawn as the
9
+ // header box's meta row, so no
10
+ // top-level bar at all
11
+ //
12
+ // `tickets` is the project/ticket drill-down; `ticket-agents` is the managed
13
+ // create-agent list (GAIA-190); `runs` is the project-scoped run overview
14
+ // (GAIA-192) — all three project-scoped, which is why they sit on the project
15
+ // level and not on the root one.
16
+ //
17
+ // Only tabs that are actually navigable are listed. A planned screen (`Users` on
18
+ // the root level, `Conductors` on the project level) arrives with its own ticket
19
+ // and adds its entry here then — a tab drawn before its screen exists would
20
+ // advertise navigation the operator cannot take, which is the very defect
21
+ // GAIA-239 removes.
22
+ //
23
+ // `as const satisfies` is load-bearing on both halves: `satisfies` still
24
+ // shape-checks every entry against `TopTab`, while `as const` keeps the literal
25
+ // ids so the id unions below can be DERIVED from them. A plain `: TopTab[]`
26
+ // annotation would widen `id` to `string` and the derivation would collapse to
27
+ // `string`, taking the compile-time check with it.
28
+ /** The root level: what there is before a project is chosen. */
29
+ export const ROOT_TABS = [
30
+ { id: 'projects', label: 'Projects' },
31
+ ];
32
+ /** The project level: the tabs of an opened project. */
33
+ export const PROJECT_TABS = [
6
34
  { id: 'tickets', label: 'Tickets' },
7
35
  { id: 'ticket-agents', label: 'Ticket-Agents' },
8
36
  { id: 'runs', label: 'Runs' },
9
37
  ];
10
- /** The top tab bar as a single row; the active tab is bracketed. */
11
- export function topTabBar(activeId) {
12
- return TOP_TABS.map((t) => t.id === activeId ? `[${t.label}]` : ` ${t.label} `).join(' ');
38
+ /** The tab bars, keyed by level the one place a level is named. */
39
+ export const TAB_LEVELS = {
40
+ root: ROOT_TABS,
41
+ project: PROJECT_TABS,
42
+ };
43
+ /** What joins two tabs in the rendered bar — part of neither tab's span. */
44
+ const SEPARATOR = ' ';
45
+ /** One tab's rendered segment: bracketed when active, space-padded otherwise. */
46
+ const segment = (tab, activeId) => tab.id === activeId ? `[${tab.label}]` : ` ${tab.label} `;
47
+ const renderBar = (tabs, activeId) => tabs.map((t) => segment(t, activeId)).join(SEPARATOR);
48
+ /**
49
+ * One level's tab bar as a single row; the active tab is bracketed.
50
+ *
51
+ * The level is part of the signature on purpose: it is what keeps the id checked
52
+ * at compile time now that there is more than one bar. `topTabBar('root', 'runs')`
53
+ * is a TS2345 naming the root level's valid ids — not a bar that silently
54
+ * brackets nothing.
55
+ */
56
+ export function topTabBar(level, activeId) {
57
+ return renderBar(TAB_LEVELS[level], activeId);
58
+ }
59
+ /**
60
+ * The same bar, for a caller holding a whole {@link TabLocation} rather than a
61
+ * literal level. The pairing was already checked when the location was built, so
62
+ * this needs no second check — and unlike `topTabBar(loc.level, loc.tab)` it does
63
+ * not ask the compiler to correlate two independently narrowed properties.
64
+ */
65
+ export function tabBarFor(location) {
66
+ return renderBar(TAB_LEVELS[location.level], location.tab);
67
+ }
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
+ const renderSpans = (tabs, activeId) => {
88
+ const spans = [];
89
+ let start = 0;
90
+ tabs.forEach((tab, index) => {
91
+ if (index > 0)
92
+ start += displayWidth(SEPARATOR);
93
+ const width = displayWidth(segment(tab, activeId));
94
+ spans.push({ id: tab.id, start, end: start + width });
95
+ start += width;
96
+ });
97
+ return spans;
98
+ };
99
+ /**
100
+ * One level's own hit map, built from the SAME `segment`/`SEPARATOR` that level's
101
+ * rendered bar is joined from — so the layout and the click mapping are two
102
+ * readings of one expression and cannot drift apart. That is the argument
103
+ * `lib/hit-test.ts` makes for the body rows, applied to a column axis.
104
+ *
105
+ * Offsets are display columns, which is the unit a mouse report carries. For the
106
+ * current ASCII labels that coincides with string offsets, which is what lets
107
+ * the test slice `topTabBar()` with a span and compare.
108
+ *
109
+ * GAIA-239: the LEVEL is part of the signature, exactly as it is for
110
+ * {@link topTabBar}, and for the same reason twice over. GAIA-241 shipped one
111
+ * `topTabSpans(activeId)` over the single flat bar; read level-blind it would
112
+ * publish the project bar's three spans on the root screen too, so a click at
113
+ * the column where `Ticket-Agents` used to sit would open a tab that screen does
114
+ * not draw — the very defect this ticket removes, arriving through the mouse.
115
+ * A map and a bar of two different levels is now a TS2345, not a silent
116
+ * mis-click.
117
+ */
118
+ export function topTabSpans(level, activeId) {
119
+ return renderSpans(TAB_LEVELS[level], activeId);
120
+ }
121
+ /**
122
+ * The same hit map, for a caller holding a whole {@link TabLocation} — the
123
+ * companion of {@link tabBarFor}, and the pairing it needs was already checked
124
+ * when the location was built. Keeping the two in step is what makes "the bar you
125
+ * see is the bar you click" true by construction: both read `location`.
126
+ */
127
+ export function tabSpansFor(location) {
128
+ return renderSpans(TAB_LEVELS[location.level], location.tab);
13
129
  }
@@ -0,0 +1,22 @@
1
+ import type { DropshClient } from '../../../types.js';
2
+ import { type OptionClients } from '../../transport.js';
3
+ /** One conductor of the control plane, as this renderer needs it. */
4
+ export interface Conductor {
5
+ id: string;
6
+ /** Never empty — the uuid is the last fallback, so nothing renders as blank. */
7
+ label: string;
8
+ /** The machine the conductor runs on. */
9
+ machineId: string | undefined;
10
+ /** Liveness, as the control plane reports it. */
11
+ status: string | undefined;
12
+ /** The conductor's project, `null` when unlinked. */
13
+ projectId: string | null;
14
+ }
15
+ /** The conductor collection through one client, in server order. */
16
+ export declare const listConductors: (client: DropshClient) => Promise<Conductor[]>;
17
+ /**
18
+ * The same collection read through the WRITE client where there is one — still just
19
+ * reading, but the write scope is the one that decides what a write may reference,
20
+ * and `pm` sees the FULL conductor set where `session` sees a subset.
21
+ */
22
+ export declare const listConductorsPreferringWrite: (clients: OptionClients) => Promise<Conductor[]>;
@@ -0,0 +1,17 @@
1
+ import { list, listPreferringWrite, } from '../../transport.js';
2
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
3
+ const toConductor = (r) => ({
4
+ id: r.id,
5
+ label: str(r.attr('label')) ?? str(r.attr('machine_id')) ?? r.id,
6
+ machineId: str(r.attr('machine_id')),
7
+ status: str(r.attr('status')),
8
+ projectId: r.rel('project_id'),
9
+ });
10
+ /** The conductor collection through one client, in server order. */
11
+ export const listConductors = (client) => list(client, 'gaia_conductor', toConductor);
12
+ /**
13
+ * The same collection read through the WRITE client where there is one — still just
14
+ * reading, but the write scope is the one that decides what a write may reference,
15
+ * and `pm` sees the FULL conductor set where `session` sees a subset.
16
+ */
17
+ export const listConductorsPreferringWrite = (clients) => listPreferringWrite(clients, 'gaia_conductor', toConductor);
@@ -0,0 +1,10 @@
1
+ /** The derived liveness state of a conductor. */
2
+ export type ConductorStatus = 'running' | 'stale' | 'offline' | 'registry-only';
3
+ /** The raw liveness inputs derived from a `gaia_conductor` record. */
4
+ export interface ConductorInput {
5
+ present?: boolean;
6
+ status?: string | null;
7
+ lastSeen?: number | null;
8
+ leaseExpiresAt?: number | null;
9
+ }
10
+ export declare function deriveConductorStatus(conductor: ConductorInput, nowSeconds: number): ConductorStatus;
@@ -0,0 +1,25 @@
1
+ // Conductor liveness derivation. Mirrors the server-side ConductorDeriver
2
+ // (web/modules/custom/gaia_core/modules/gaia_dashboard/src/Dashboard/ConductorDeriver.php)
3
+ // and the CLI `classify()` in gaia-cli/conductor/src/commands/conductor.ts:
4
+ // no gaia_conductor record → registry-only; status 'offline' → offline; else
5
+ // stale when last_seen older than 600s OR the lease has expired; else running.
6
+ const STALE_WINDOW_SECONDS = 600;
7
+ export function deriveConductorStatus(conductor, nowSeconds) {
8
+ if (!conductor?.present)
9
+ return 'registry-only';
10
+ if (conductor.status === 'offline')
11
+ return 'offline';
12
+ if (conductor.lastSeen == null)
13
+ return 'stale';
14
+ if (nowSeconds - conductor.lastSeen > STALE_WINDOW_SECONDS)
15
+ return 'stale';
16
+ if (conductor.leaseExpiresAt != null &&
17
+ conductor.leaseExpiresAt < nowSeconds) {
18
+ return 'stale';
19
+ }
20
+ return 'running';
21
+ }
22
+ // `humanizeAge` moved to `lib/format.ts` (GAIA-236, entity-slice Phase 0): it is a
23
+ // generic relative-time formatter with no conductor vocabulary, and keeping it here
24
+ // is what made `Component/comment-item` and `Deriver/run-status` import a CONDUCTOR
25
+ // module. Its test moved with it, to `tests/lib/format.test.ts`.
@@ -0,0 +1,8 @@
1
+ /** The minimum shape a dashboard row needs to render. */
2
+ export interface DashboardRowInput {
3
+ project: string;
4
+ derivedStatus: string;
5
+ ageLabel: string;
6
+ }
7
+ /** One line per project: name, derived status, relative age. */
8
+ export declare function dashboardRows(projects: DashboardRowInput[]): 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 dashboardRows(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
+ }
@@ -0,0 +1,15 @@
1
+ /** A project that claims the repo. */
2
+ export interface PickerCandidate {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ /**
7
+ * The picker screen's rows: a heading naming the repo, then one row per
8
+ * candidate with `▸` on the selected one.
9
+ */
10
+ export declare function projectPickerRows(candidates: PickerCandidate[], repo: string, selected: number): string[];
11
+ /**
12
+ * The non-TTY form (spec Decision 11): the candidate list plus the direct
13
+ * command, printed once so `gaia ui --here | cat` stays scriptable — no prompt.
14
+ */
15
+ export declare function projectPickerHint(candidates: PickerCandidate[], repo: string): string;