@gaia-ai/addon-gaia-ui 0.12.0 → 0.13.1

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.
@@ -402,6 +402,16 @@ export interface ConductorQuery {
402
402
  * is the convention's cost, not this read's decision to make.
403
403
  */
404
404
  export declare const listConductorRowPage: (client: DropshClient, ctx: ConductorRowContext, query: ConductorQuery, page?: number) => Promise<Page<ConductorRow>>;
405
+ /**
406
+ * A single conductor's own `last_seen`, or `undefined` when the record has
407
+ * none or the read fails.
408
+ *
409
+ * GAIA-477 AC-5: the cockpit's `act('start', …)` polls this after a spawn,
410
+ * comparing against the row's `lastSeen` captured before the start, to tell
411
+ * a genuine new heartbeat apart from a stale one still in the window — the
412
+ * same distinction the CLI's `waitForFreshHeartbeat` makes server-side.
413
+ */
414
+ export declare const fetchConductorLastSeen: (client: DropshClient, id: string) => Promise<number | undefined>;
405
415
  /**
406
416
  * How large the collection under `query` is, or `null` when that is not
407
417
  * established (GAIA-460 D4).
@@ -428,6 +428,27 @@ export const listConductorRowPage = async (client, ctx, query, page = 0) => {
428
428
  return data.map((raw) => toConductorRow(raw, ctx, machinesById));
429
429
  }, page, window);
430
430
  };
431
+ /**
432
+ * A single conductor's own `last_seen`, or `undefined` when the record has
433
+ * none or the read fails.
434
+ *
435
+ * GAIA-477 AC-5: the cockpit's `act('start', …)` polls this after a spawn,
436
+ * comparing against the row's `lastSeen` captured before the start, to tell
437
+ * a genuine new heartbeat apart from a stale one still in the window — the
438
+ * same distinction the CLI's `waitForFreshHeartbeat` makes server-side.
439
+ */
440
+ export const fetchConductorLastSeen = async (client, id) => {
441
+ try {
442
+ const doc = (await client.get(`gaia_conductor/gaia_conductor/${id}`));
443
+ const raw = doc?.data;
444
+ if (!raw)
445
+ return undefined;
446
+ return toUnixSeconds(asResource(raw).attr('last_seen')) ?? undefined;
447
+ }
448
+ catch {
449
+ return undefined;
450
+ }
451
+ };
431
452
  /**
432
453
  * How large the collection under `query` is, or `null` when that is not
433
454
  * established (GAIA-460 D4).
@@ -1,4 +1,5 @@
1
1
  import { type SignalOutcome } from '@gaia-ai/core';
2
+ import type { DropshClient } from '../../../types.js';
2
3
  /** What a cockpit action reports back to its row. */
3
4
  export interface ConductorActionResult {
4
5
  ok: boolean;
@@ -35,6 +36,16 @@ export interface ConductorProcessDeps {
35
36
  * stop needs this machine's own value to compare against).
36
37
  */
37
38
  thisHostKey?: () => Promise<string>;
39
+ /**
40
+ * GAIA-477 AC-5: how `start` learns whether the spawn actually came online,
41
+ * injected so tests need no timers or network. Defaults to
42
+ * {@link fetchConductorLastSeen} against the real control plane.
43
+ */
44
+ fetchLastSeen?: (client: DropshClient, id: string) => Promise<number | undefined>;
45
+ sleep?: (ms: number) => Promise<void>;
46
+ /** Bounded poll budget; small defaults so a real failure resolves quickly. */
47
+ heartbeatPollAttempts?: number;
48
+ heartbeatPollIntervalMs?: number;
38
49
  }
39
50
  export interface ConductorProcessHost {
40
51
  start(input: {
@@ -43,6 +54,15 @@ export interface ConductorProcessHost {
43
54
  /** The row's `host_key` (GAIA-450 review B5) — verified against this
44
55
  * machine's own before anything is spawned. */
45
56
  hostKey: string | undefined;
57
+ /**
58
+ * GAIA-477 AC-5: the conductor's own record id and its `last_seen`
59
+ * captured before the spawn, and the client to poll with. Present only
60
+ * when the caller can observe the outcome — absent, `start` reports the
61
+ * spawn as before and never claims to have seen it come online.
62
+ */
63
+ id?: string | undefined;
64
+ priorLastSeen?: number | undefined;
65
+ client?: DropshClient | undefined;
46
66
  }): Promise<ConductorActionResult>;
47
67
  stop(input: {
48
68
  pid: number | undefined;
@@ -1,5 +1,6 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import { machineContextPath, readMachineContext, signalVerifiedProcess, } from '@gaia-ai/core';
3
+ import { fetchConductorLastSeen } from './conductors.js';
3
4
  /**
4
5
  * This machine's host_key, from the same MachineContext the CLI reads.
5
6
  *
@@ -28,9 +29,13 @@ export function createConductorProcessHost(deps = {}) {
28
29
  const spawn = deps.spawn ?? defaultSpawn;
29
30
  const signal = deps.signal ?? signalVerifiedProcess;
30
31
  const thisHostKey = deps.thisHostKey ?? defaultThisHostKey;
32
+ const fetchLastSeen = deps.fetchLastSeen ?? fetchConductorLastSeen;
33
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
34
+ const heartbeatPollAttempts = deps.heartbeatPollAttempts ?? 5;
35
+ const heartbeatPollIntervalMs = deps.heartbeatPollIntervalMs ?? 1000;
31
36
  return {
32
37
  thisHostKey,
33
- async start({ workspaceRoot, conductorName, hostKey }) {
38
+ async start({ workspaceRoot, conductorName, hostKey, id, priorLastSeen, client, }) {
34
39
  // GAIA-450 review B5: the only gate this used to have was `isLocal` — a
35
40
  // filesystem check on `workspaceRoot`, which answers "does this path
36
41
  // exist on THIS disk", not "is this THIS machine's conductor". Two
@@ -68,6 +73,30 @@ export function createConductorProcessHost(deps = {}) {
68
73
  GAIA_CONDUCTOR_CONFIG: '',
69
74
  },
70
75
  });
76
+ // GAIA-477 AC-5: `spawn` settling only proves the fork happened, not
77
+ // that a foreground conductor is running under it — the herdr pane
78
+ // boundary this ticket's other fix crosses can still leave a row
79
+ // silently offline. Watch for a genuinely NEW heartbeat (never the
80
+ // stale one already on the row) over a bounded budget before telling
81
+ // the operator this succeeded, the same distinction the CLI's own
82
+ // `waitForFreshHeartbeat` makes.
83
+ if (id && client) {
84
+ for (let attempt = 0; attempt < heartbeatPollAttempts; attempt += 1) {
85
+ await sleep(heartbeatPollIntervalMs);
86
+ const lastSeen = await fetchLastSeen(client, id);
87
+ if (lastSeen !== undefined &&
88
+ (priorLastSeen === undefined || lastSeen > priorLastSeen)) {
89
+ return {
90
+ ok: true,
91
+ message: `starting ${conductorName ?? 'conductor'} in ${workspaceRoot}`,
92
+ };
93
+ }
94
+ }
95
+ return {
96
+ ok: false,
97
+ message: `${conductorName ?? 'conductor'} did not come online — spawned in ${workspaceRoot} but no fresh heartbeat arrived`,
98
+ };
99
+ }
71
100
  return {
72
101
  ok: true,
73
102
  message: `starting ${conductorName ?? 'conductor'} in ${workspaceRoot}`,
@@ -303,6 +303,9 @@ export class ConductorsScreen extends ListScreen {
303
303
  workspaceRoot: row.workspaceRoot,
304
304
  conductorName: row.conductorName,
305
305
  hostKey: row.hostKey,
306
+ id: row.id,
307
+ priorLastSeen: row.lastSeen || undefined,
308
+ client: context.services?.client,
306
309
  })
307
310
  : await host.stop({
308
311
  pid: row.lastPid,
@@ -39,18 +39,31 @@ export interface SeriesFigureValue {
39
39
  days: StatisticDay[];
40
40
  }
41
41
  /**
42
- * A `ratio` figure's value: its two named factors, side by side.
42
+ * A `ratio` figure's value: the quotient, and the two factors behind it.
43
43
  *
44
- * The ratio itself is computed CLIENT-SIDE from the two totals — a
45
- * server-computed copy would be a second source of truth that can disagree
46
- * (unchanged from GAIA-377's `closedPerRun`, now over server-named factors
47
- * instead of the fixed tickets/runs pair).
44
+ * The division was CLIENT-SIDE until GAIA-481 D3, on the reasoning that a
45
+ * server-computed copy would be a second source of truth. The web Stats tabs
46
+ * reversed that reasoning: a second SURFACE would have had to re-implement
47
+ * the same rule in PHP, so the division and its zero-denominator case moved
48
+ * to the server instead. `ratio` is therefore READ and rendered, never
49
+ * recomputed here — and it arrives already formatted, because the empty
50
+ * value is part of the rule rather than a presentation choice each surface
51
+ * makes for itself.
52
+ *
53
+ * `numerator` and `denominator` still travel beside it, unchanged.
48
54
  */
49
55
  export interface RatioFigureValue {
50
56
  generated: string;
57
+ ratio: RatioQuotients;
51
58
  numerator: WindowsFigureValue;
52
59
  denominator: WindowsFigureValue;
53
60
  }
61
+ /** A ratio's derived value per trailing window: two decimals, or `—`. */
62
+ export interface RatioQuotients {
63
+ day: string;
64
+ week: string;
65
+ month: string;
66
+ }
54
67
  export type FigureValue = WindowsFigureValue | SeriesFigureValue | RatioFigureValue;
55
68
  /** One figure discovery names: which blocks the caller may render (spec D5). */
56
69
  export interface StatisticListing {
@@ -132,11 +145,3 @@ export declare function loadStatisticValue(client: DropshClient, statisticId: st
132
145
  * its own (spec D5). Figures already arrive weight-ordered from discovery.
133
146
  */
134
147
  export declare function loadGroupStatistics(client: DropshClient, scopeId: string): Promise<GroupStatisticsResult>;
135
- /** Closed-per-run-shaped ratio, for each trailing window, from two totals. */
136
- export declare function ratioOf(numerator: StatisticsWindows, denominator: StatisticsWindows): {
137
- day: number | null;
138
- week: number | null;
139
- month: number | null;
140
- };
141
- /** Format a ratio value for the screen: two decimals, or `—`. */
142
- export declare const formatRatio: (value: number | null) => string;
@@ -1,4 +1,12 @@
1
1
  import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
2
+ /**
3
+ * What a ratio window reads as when there is nothing to divide by.
4
+ *
5
+ * The server owns this rule and sends the character; this constant exists
6
+ * only so a response that carries no quotient at all degrades to the same
7
+ * thing rather than to an empty cell.
8
+ */
9
+ const RATIO_EMPTY = '—';
2
10
  /** How an `id: null` breakdown row is named on screen. */
3
11
  export const UNATTRIBUTED_LABEL = '— unattributed';
4
12
  const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
@@ -43,6 +51,23 @@ function windowsFigureOf(attributes) {
43
51
  ...(rows ? { rows } : {}),
44
52
  };
45
53
  }
54
+ /**
55
+ * A ratio figure's quotients, as the server formatted them (GAIA-481 D3).
56
+ *
57
+ * A control plane that predates the move sends no `ratio` at all, and this
58
+ * reads the empty value for every window rather than throwing or dividing a
59
+ * replacement: an older server is a missing figure, not a wrong one, and the
60
+ * one thing this must not do is quietly reintroduce the division it replaced.
61
+ */
62
+ function quotientsOf(value) {
63
+ const source = (value ?? {});
64
+ const windowOf = (key) => typeof source[key] === 'string' ? source[key] : RATIO_EMPTY;
65
+ return {
66
+ day: windowOf('day'),
67
+ week: windowOf('week'),
68
+ month: windowOf('month'),
69
+ };
70
+ }
46
71
  function figureValueOf(attributes) {
47
72
  if (Array.isArray(attributes.days)) {
48
73
  return {
@@ -56,6 +81,7 @@ function figureValueOf(attributes) {
56
81
  attributes.denominator !== null) {
57
82
  return {
58
83
  generated: typeof attributes.generated === 'string' ? attributes.generated : '',
84
+ ratio: quotientsOf(attributes.ratio),
59
85
  numerator: windowsFigureOf(attributes.numerator),
60
86
  denominator: windowsFigureOf(attributes.denominator),
61
87
  };
@@ -198,14 +224,12 @@ export async function loadGroupStatistics(client, scopeId) {
198
224
  }
199
225
  return { kind: 'ok', figures };
200
226
  }
201
- /** Closed-per-run-shaped ratio, for each trailing window, from two totals. */
202
- export function ratioOf(numerator, denominator) {
203
- const ratio = (n, d) => (d > 0 ? n / d : null);
204
- return {
205
- day: ratio(numerator.day, denominator.day),
206
- week: ratio(numerator.week, denominator.week),
207
- month: ratio(numerator.month, denominator.month),
208
- };
209
- }
210
- /** Format a ratio value for the screen: two decimals, or `—`. */
211
- export const formatRatio = (value) => value === null ? '—' : value.toFixed(2);
227
+ // GAIA-481 D3 — `ratioOf` and `formatRatio` were exported from here, and are
228
+ // gone. A `ratio` figure's quotient is the server's now
229
+ // (`RatioFigureValue.ratio`), so this module deliberately offers no ratio
230
+ // arithmetic for a caller to reach for: a shared helper sitting beside the
231
+ // server's own value is exactly how the second implementation would come
232
+ // back. The team Users screen still divides, because it derives a PER-MEMBER
233
+ // quotient out of two breakdown figures' rows and no configured figure
234
+ // produces that one; it owns that arithmetic privately rather than sharing
235
+ // it from here.
@@ -1,5 +1,4 @@
1
1
  import { statsPanelRows, } from '../../Component/stats-panel/index.js';
2
- import { formatRatio, ratioOf } from './Data/statistics.js';
3
2
  // GAIA-456 spec D2/D7 — mapping the four configured display kinds onto the
4
3
  // renderers the TUI already had: `windows` is the totals row GAIA-256 drew,
5
4
  // `breakdown` is the label-by-window table, `series` is the seven-day strip
@@ -24,13 +23,19 @@ function seriesLines(days) {
24
23
  const counts = ` ${'Count'.padEnd(LABEL - 2, ' ')}${days.map((row) => cell(String(row.count))).join('')}`;
25
24
  return [weekdays, dates, counts];
26
25
  }
27
- /** A ratio figure's two factor totals, divided per trailing window. */
28
- function ratioLines(numerator, denominator) {
29
- const ratios = ratioOf(numerator, denominator);
26
+ /**
27
+ * A ratio figure's quotient per trailing window, as the SERVER divided it.
28
+ *
29
+ * The values arrive already formatted (GAIA-481 D3), so this lays them out
30
+ * and does nothing else — no division and no em-dash rule, both of which are
31
+ * the server's now and would otherwise be a terminal-only second opinion on
32
+ * the same figure the browser renders.
33
+ */
34
+ function ratioLines(ratios) {
30
35
  return [
31
- ` ${WINDOWS[0]} ${formatRatio(ratios.day)}`,
32
- ` ${WINDOWS[1]} ${formatRatio(ratios.week)}`,
33
- ` ${WINDOWS[2]} ${formatRatio(ratios.month)}`,
36
+ ` ${WINDOWS[0]} ${ratios.day}`,
37
+ ` ${WINDOWS[1]} ${ratios.week}`,
38
+ ` ${WINDOWS[2]} ${ratios.month}`,
34
39
  ];
35
40
  }
36
41
  /** One figure's own section: its label as the heading, its display kind's lines. */
@@ -41,10 +46,7 @@ export function figureSection({ listing, value }) {
41
46
  return { heading, lines: seriesLines(value.days) };
42
47
  case 'ratio': {
43
48
  const ratio = value;
44
- return {
45
- heading,
46
- lines: ratioLines(ratio.numerator.total, ratio.denominator.total),
47
- };
49
+ return { heading, lines: ratioLines(ratio.ratio) };
48
50
  }
49
51
  case 'breakdown': {
50
52
  const windows = value;
@@ -4,7 +4,7 @@ import { ListCursor } from '../../../lib/cursor.js';
4
4
  import { columnNode } from '../../../lib/vnode.js';
5
5
  import { AsyncScreen } from '../../../Screen/async-screen.js';
6
6
  import { listRowIds, listTableTree } from '../../collection.js';
7
- import { formatRatio, loadGroupStatistics, ratioOf, } from '../../statistics/Data/statistics.js';
7
+ import { loadGroupStatistics, } from '../../statistics/Data/statistics.js';
8
8
  import { listUsers, TEAM_MEMBER_TABLE, } from '../../user/Data/users.js';
9
9
  import { listTeamMemberIds } from '../Data/members.js';
10
10
  import { TeamsScreen } from './teams-screen.js';
@@ -48,6 +48,20 @@ function rowsById(statistics, statisticId) {
48
48
  }
49
49
  return map;
50
50
  }
51
+ /**
52
+ * This screen's own closed-per-run, for ONE member over the 30-day window.
53
+ *
54
+ * Deliberately local and unexported (GAIA-481 D3). A `ratio` FIGURE is
55
+ * divided by the server, and this is not one: it is derived per member from
56
+ * two breakdown figures' rows, and no configured figure produces it. Keeping
57
+ * it here rather than in the shared statistics module is what stops it being
58
+ * mistaken for the general "how a ratio is computed" helper and reached for
59
+ * by a surface that should be reading the server's value instead.
60
+ *
61
+ * The rule matches the server's so the two columns agree on screen: two
62
+ * decimals, and an em-dash when there is nothing to divide by.
63
+ */
64
+ const perMemberRatio = (numerator, denominator) => denominator > 0 ? (numerator / denominator).toFixed(2) : '—';
51
65
  /** One member's row: identity from the collection, figures from the two per-user breakdowns. */
52
66
  function rowOf(user, ticketsById, runsById) {
53
67
  const tickets30d = ticketsById.get(user.id)?.month ?? 0;
@@ -57,7 +71,7 @@ function rowOf(user, ticketsById, runsById) {
57
71
  label: user.label,
58
72
  tickets30d,
59
73
  runs30d,
60
- closedPerRun30d: formatRatio(ratioOf({ day: 0, week: 0, month: tickets30d }, { day: 0, week: 0, month: runs30d }).month),
74
+ closedPerRun30d: perMemberRatio(tickets30d, runs30d),
61
75
  };
62
76
  }
63
77
  /**
@@ -1,6 +1,6 @@
1
1
  import type { VNode } from '@opentui/core';
2
- import type { Section } from '../../../../Component/section-list/index.js';
3
- import type { PanelSection } from '../../../../Extension/contract.js';
2
+ import type { Section, SectionLine } from '../../../../Component/section-list/index.js';
3
+ import type { PanelColumn, PanelSection } from '../../../../Extension/contract.js';
4
4
  import { type StyledLine } from '../../../../lib/vnode.js';
5
5
  import type { TicketDetailDocument } from '../../../../types.js';
6
6
  import type { CommentItem } from '../comment-item/index.js';
@@ -142,6 +142,35 @@ export declare function detailLines(detail?: TicketDetailDocument | null, labels
142
142
  boldRows: Set<number>;
143
143
  };
144
144
  /** One rendered ticket section: its body, and the selection map into it. */
145
+ /** A row of a contributed table — identity is required, the rest is the addon's. */
146
+ export interface PanelTableRow {
147
+ readonly id: string;
148
+ }
149
+ /**
150
+ * GAIA-475 — what the screen resolved out of a `UiTable`, ready to lay out.
151
+ *
152
+ * Data only, and deliberately not the `UiTable` itself: by the time it reaches
153
+ * here `columns()` and `rows()` have already been called inside the screen's
154
+ * error boundary, so this component never invokes contributed code and cannot be
155
+ * the thing that throws.
156
+ */
157
+ export interface PanelTableData {
158
+ /** The id prefix every node of this list carries; derived from the table id. */
159
+ readonly noun: string;
160
+ readonly columns: readonly PanelColumn<PanelTableRow>[];
161
+ readonly rows: readonly PanelTableRow[];
162
+ readonly emptyMessage: string;
163
+ }
164
+ /**
165
+ * GAIA-475 A4 — a contributed table as the PIPE's text, one serialisation reused.
166
+ *
167
+ * `listTableRows` is the same function the built-in lists print through, so the
168
+ * piped form of a contributed table is the piped form of every other list rather
169
+ * than a second renderer that drifts from it. It is also what keeps the contract
170
+ * honest: a contribution supplied data, so BOTH renderings are derivable, and
171
+ * neither is the addon's problem.
172
+ */
173
+ export declare const panelTableLines: (data: PanelTableData) => SectionLine[];
145
174
  export interface TabView {
146
175
  /** GAIA-340 D1 — the tab's rows as the renderable tree the screen hands over. */
147
176
  readonly body: VNode;
@@ -235,7 +264,7 @@ export declare function firstLineSummary(lines: readonly StyledLine[]): StyledLi
235
264
  * off — the Runs tab, by contrast, is one node per run plus six cells and is
236
265
  * bounded by the document's sideloaded runs.
237
266
  */
238
- export declare function tabViewTree(detail: TicketDetailDocument | null | undefined, tabIndex: number, { expanded, nowSeconds, labels, selectedIndex, panelSections, }?: {
267
+ export declare function tabViewTree(detail: TicketDetailDocument | null | undefined, tabIndex: number, { expanded, nowSeconds, labels, selectedIndex, panelSections, panelTable, }?: {
239
268
  expanded?: ReadonlySet<number>;
240
269
  nowSeconds?: number;
241
270
  labels?: AssignmentLabels;
@@ -249,4 +278,14 @@ export declare function tabViewTree(detail: TicketDetailDocument | null | undefi
249
278
  * `PanelSection[]` contract (spec D4): an addon restates none of it.
250
279
  */
251
280
  panelSections?: readonly PanelSection[];
281
+ /**
282
+ * GAIA-475 — a contributed table's columns and rows, already resolved by the
283
+ * screen, exactly as `panelSections` is.
284
+ *
285
+ * Present ⇒ this tab is a TABLE and `tabIndex` names no built-in section.
286
+ * Resolved by the screen rather than here for the same reason `panelSections`
287
+ * is: the contribution is the SCREEN's dependency, and this stays a pure
288
+ * function of rows.
289
+ */
290
+ panelTable?: PanelTableData;
252
291
  }): TabView;
@@ -686,6 +686,37 @@ const EXPANDABLE_HEADINGS = new Set([
686
686
  * be one somewhere else, while `ticket-detail-description-0` is this screen's.
687
687
  */
688
688
  const BODY_ID = 'ticket-detail';
689
+ /**
690
+ * The contributed table as the `ListTable` the host's renderer speaks.
691
+ *
692
+ * A translation and nothing more — `fields` is `[]` because sparse fieldsets are
693
+ * a JSON:API query's business and a contribution issues none, and `get` drops the
694
+ * `Ctx` argument because a contribution's context arrived with `PanelRender`.
695
+ */
696
+ const listTableOf = (data) => ({
697
+ noun: data.noun,
698
+ emptyMessage: data.emptyMessage,
699
+ columns: data.columns.map((column) => ({
700
+ key: column.key,
701
+ label: column.label,
702
+ width: column.width,
703
+ fields: [],
704
+ ...(column.min !== undefined ? { min: column.min } : {}),
705
+ ...(column.max !== undefined ? { max: column.max } : {}),
706
+ ...(column.grow !== undefined ? { grow: column.grow } : {}),
707
+ get: (row) => column.get(row),
708
+ })),
709
+ });
710
+ /**
711
+ * GAIA-475 A4 — a contributed table as the PIPE's text, one serialisation reused.
712
+ *
713
+ * `listTableRows` is the same function the built-in lists print through, so the
714
+ * piped form of a contributed table is the piped form of every other list rather
715
+ * than a second renderer that drifts from it. It is also what keeps the contract
716
+ * honest: a contribution supplied data, so BOTH renderings are derivable, and
717
+ * neither is the addon's problem.
718
+ */
719
+ export const panelTableLines = (data) => listTableRows(listTableOf(data), data.rows, undefined);
689
720
  /**
690
721
  * One expandable entry: the caret header, the D1 extract while collapsed, the
691
722
  * whole body while expanded.
@@ -853,7 +884,7 @@ function sectionTabRows(sections, { expanded, expandableHeadings = EXPANDABLE_HE
853
884
  * off — the Runs tab, by contrast, is one node per run plus six cells and is
854
885
  * bounded by the document's sideloaded runs.
855
886
  */
856
- export function tabViewTree(detail, tabIndex, { expanded = new Set(), nowSeconds = 0, labels, selectedIndex = -1, panelSections, } = {}) {
887
+ export function tabViewTree(detail, tabIndex, { expanded = new Set(), nowSeconds = 0, labels, selectedIndex = -1, panelSections, panelTable, } = {}) {
857
888
  if (panelSections) {
858
889
  const sections = panelSections.map((section) => ({
859
890
  heading: section.heading,
@@ -871,6 +902,22 @@ export function tabViewTree(detail, tabIndex, { expanded = new Set(), nowSeconds
871
902
  keyOf: panelSectionKey,
872
903
  }), selectedIndex);
873
904
  }
905
+ // GAIA-475 A2 — a contributed TABLE, mapped onto the very machinery the `runs`
906
+ // branch below uses. Same `listTableTree`, same `1 + index` header arithmetic,
907
+ // same `listRowIds`: the contribution supplied columns and rows, and every
908
+ // question after that — layout, the header row, the highlight, the click map —
909
+ // is answered by code that already existed. That is the whole reason the
910
+ // contract carries data instead of a render tree.
911
+ if (panelTable) {
912
+ const { rows } = panelTable;
913
+ const table = listTableOf(panelTable);
914
+ return {
915
+ body: columnNode(BODY_ID, listTableTree(table, rows, undefined, { selectedIndex })),
916
+ headerRows: rows.map((_row, index) => 1 + index),
917
+ selectable: rows.length,
918
+ rowIds: listRowIds(table, rows),
919
+ };
920
+ }
874
921
  const id = TICKET_SECTIONS[tabIndex]?.id;
875
922
  if (id === 'runs') {
876
923
  const runs = ticketRunRows(detail ?? {});
@@ -57,6 +57,25 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
57
57
  */
58
58
  selectTopTab(tabId: string, context: ScreenContext): boolean;
59
59
  reload(context: ScreenContext): void;
60
+ /**
61
+ * GAIA-475 criterion 24 — arm or cancel the table's self-refresh.
62
+ *
63
+ * Called wherever the OPEN TAB can change: `enter`, `selectTab`, and after an
64
+ * activation. A contributed table is a view of the machine rather than of the
65
+ * ticket — a Storybook started ten minutes into a run is not in any document
66
+ * the host fetched — so it is the one tab that has to re-read without being
67
+ * asked. The built-in tabs deliberately do not tick: their data comes from the
68
+ * API, and a timer on them would be a polling loop nobody asked for.
69
+ *
70
+ * What a tick does is the cheap half of `reload`: bump the token and redraw.
71
+ * It does NOT call `retry()`, so no HTTP request is made — the contribution
72
+ * re-reads its own source (the socket table, the state file) and everything
73
+ * the screen shows about the ticket is left exactly as it was.
74
+ */
75
+ private syncTableTick;
76
+ private armTableTick;
77
+ private cancelTableTick;
78
+ leave(): void;
60
79
  static readonly route: {
61
80
  id: string;
62
81
  path: string;
@@ -86,6 +105,19 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
86
105
  */
87
106
  private panelQuery;
88
107
  private panelParams;
108
+ /** GAIA-475 — the table rows whose `onActivate` is in flight. */
109
+ private activatingRows;
110
+ /** GAIA-475 A5 — bumped on every reload, handed to a table so it can drop its
111
+ * cache. A counter, not a clock. */
112
+ private reloadToken;
113
+ /**
114
+ * GAIA-475 criterion 24 — the self-refresh, as its cancel function.
115
+ *
116
+ * Set while a contributed TABLE is the open tab, `undefined` otherwise, so the
117
+ * field's presence IS "a tick is armed" and there is no second flag to
118
+ * disagree with it.
119
+ */
120
+ private tableTick;
89
121
  /** GAIA-326 — the last copy's outcome, shown in the footer. Empty until the
90
122
  * operator asks for one; see `copyOpenTicket`. */
91
123
  private status;
@@ -98,7 +130,7 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
98
130
  setLabels(labels: AssignmentLabels | undefined): void;
99
131
  selectTab(index: number, context: ScreenContext): boolean;
100
132
  select(index: number, context: ScreenContext): boolean;
101
- activate(index: number, context: ScreenContext): boolean;
133
+ activate(index: number, context: ScreenContext, intent?: 'activate' | 'deactivate'): boolean;
102
134
  get document(): TicketDetailDocument | undefined;
103
135
  protected load(context: ScreenContext): Promise<TicketDetailDocument>;
104
136
  protected renderLoading(): string[];
@@ -178,8 +210,8 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
178
210
  reset(): void;
179
211
  private initialize;
180
212
  private currentView;
181
- /** The panel the active index names, or none while a built-in is open. */
182
- private activePanel;
213
+ /** The contribution the active index names, or none while a built-in is open. */
214
+ private activeContribution;
183
215
  /**
184
216
  * One panel's sections, with its own explicit empty state applied.
185
217
  *
@@ -206,5 +238,62 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
206
238
  * untouched, so `emptyMessage` still means "nothing to show", never "it
207
239
  * broke".
208
240
  */
241
+ /**
242
+ * GAIA-475 A3 — activate one table row, following the conductor screen's shape.
243
+ *
244
+ * Mark busy, redraw, await, record the outcome as the footer status, clear busy
245
+ * in a `finally`, redraw. The busy set is what makes a second Enter a no-op
246
+ * while the first is in flight (US-12) and what a cell reads through
247
+ * `PanelRender.activating` to show the work.
248
+ *
249
+ * The row is picked from the SAME guarded resolution the render uses
250
+ * (`tableView`), so the row `onActivate` receives is exactly the row the
251
+ * operator saw; a table whose `rows()` throws resolves to the error branch and
252
+ * simply has no row to activate.
253
+ */
254
+ /**
255
+ * GAIA-500 — `l`/`t`'s shared plumbing: resolve the active table's selected
256
+ * row and ask its `copyLocal`/`copyTunnel`, returning `undefined` when there
257
+ * is no active table, the table does not implement that side, or the row has
258
+ * nothing to offer — in every one of which cases the key is left UNHANDLED
259
+ * (the caller falls through) rather than reporting a copy that did not
260
+ * happen, exactly as `l`/`t` do nothing on every tab that is not a table.
261
+ */
262
+ private copyActiveTableRow;
263
+ private activateTableRow;
264
+ /** Tests and the busy indicator: the row ids whose activation is in flight. */
265
+ activatingRowIds(): string[];
266
+ /**
267
+ * One contributed table's columns and rows — `panelSections`' twin (GAIA-475).
268
+ *
269
+ * Returns the OPTION BAG `tabViewTree` takes rather than the data directly,
270
+ * because a failure has to change WHICH branch renders: there is no such thing
271
+ * as a table of an error. On a throw this degrades to `panelSections`, so a
272
+ * broken table produces the same named, readable failure row a broken panel
273
+ * does — one rendering of "this addon broke", not two.
274
+ *
275
+ * The same chokepoint argument applies here as there, and more sharply: both
276
+ * `columns()` and `rows()` are contributed code, both run on the draw path, and
277
+ * the draw path is not uniformly guarded (`Kernel/tui-kernel.ts` wires `draw`
278
+ * straight onto the resize and mouse-scroll events). A throw out of either one
279
+ * would freeze or crash the cockpit.
280
+ *
281
+ * `noun` replaces `/` with `-` because the id is org-prefixed by convention
282
+ * (`gaia/ports`) and it becomes a node id prefix here — `rowNodeId(noun, …)` is
283
+ * what a click resolves through, so the separator has to survive that round
284
+ * trip intact.
285
+ */
286
+ private tableView;
287
+ /**
288
+ * One contributed table as the pipe's lines — the SAME resolution the draw
289
+ * path uses, so the two renderings cannot disagree about what the table holds.
290
+ *
291
+ * Routed through `tableView`, which means the error boundary covers the pipe
292
+ * too: a throwing table prints its named failure row here exactly as it draws
293
+ * one on screen.
294
+ */
295
+ private tableLines;
296
+ /** The `PanelRender` a contribution is handed — one construction, two kinds. */
297
+ private panelRender;
209
298
  private panelSections;
210
299
  }