@gaia-ai/addon-gaia-ui 0.12.0 → 0.13.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.
@@ -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,
@@ -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,53 @@ 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
+ private activateTableRow;
255
+ /** Tests and the busy indicator: the row ids whose activation is in flight. */
256
+ activatingRowIds(): string[];
257
+ /**
258
+ * One contributed table's columns and rows — `panelSections`' twin (GAIA-475).
259
+ *
260
+ * Returns the OPTION BAG `tabViewTree` takes rather than the data directly,
261
+ * because a failure has to change WHICH branch renders: there is no such thing
262
+ * as a table of an error. On a throw this degrades to `panelSections`, so a
263
+ * broken table produces the same named, readable failure row a broken panel
264
+ * does — one rendering of "this addon broke", not two.
265
+ *
266
+ * The same chokepoint argument applies here as there, and more sharply: both
267
+ * `columns()` and `rows()` are contributed code, both run on the draw path, and
268
+ * the draw path is not uniformly guarded (`Kernel/tui-kernel.ts` wires `draw`
269
+ * straight onto the resize and mouse-scroll events). A throw out of either one
270
+ * would freeze or crash the cockpit.
271
+ *
272
+ * `noun` replaces `/` with `-` because the id is org-prefixed by convention
273
+ * (`gaia/ports`) and it becomes a node id prefix here — `rowNodeId(noun, …)` is
274
+ * what a click resolves through, so the separator has to survive that round
275
+ * trip intact.
276
+ */
277
+ private tableView;
278
+ /**
279
+ * One contributed table as the pipe's lines — the SAME resolution the draw
280
+ * path uses, so the two renderings cannot disagree about what the table holds.
281
+ *
282
+ * Routed through `tableView`, which means the error boundary covers the pipe
283
+ * too: a throwing table prints its named failure row here exactly as it draws
284
+ * one on screen.
285
+ */
286
+ private tableLines;
287
+ /** The `PanelRender` a contribution is handed — one construction, two kinds. */
288
+ private panelRender;
209
289
  private panelSections;
210
290
  }
@@ -6,13 +6,23 @@ import { copyThrough } from '../../../lib/clipboard.js';
6
6
  import { BORDER, columnNode, DEFAULT_FOREGROUND, textNode, } from '../../../lib/vnode.js';
7
7
  import { defineContext } from '../../../Routing/context.js';
8
8
  import { AsyncScreen } from '../../../Screen/async-screen.js';
9
+ import { timerDebounce } from '../../../Widget/option-source.js';
9
10
  import { ticketRunRows } from '../../run/Data/runs.js';
10
11
  import { TICKET_SECTIONS } from '../Component/sections/index.js';
11
- import { detailLines, firstLineSummary, headerLabels, headerState, initialExpandedComments, tabViewTree, } from '../Component/ticket-detail/index.js';
12
+ import { detailLines, firstLineSummary, headerLabels, headerState, initialExpandedComments, panelTableLines, tabViewTree, } from '../Component/ticket-detail/index.js';
12
13
  import { ticketTeaserLines, ticketTeaserTree, } from '../Component/ticket-teaser/index.js';
13
14
  import { loadTicketDetail } from '../Data/detail.js';
14
15
  import { ticketUrl } from '../Data/url.js';
15
16
  export const ticketDetailInputContext = defineContext('ticket-detail-input', 'input');
17
+ /**
18
+ * GAIA-475 criterion 24 — how often an open contributed table re-reads itself.
19
+ *
20
+ * "Every few seconds", and five is what that phrase buys: long enough that a
21
+ * discovery costing a socket-table read and a `ddev describe` is nowhere near a
22
+ * per-frame cost (spec C6 bounds exactly this), short enough that a service
23
+ * started mid-run shows up while the operator is still looking for it.
24
+ */
25
+ const TABLE_REFRESH_MS = 5_000;
16
26
  export class TicketDetailScreen extends AsyncScreen {
17
27
  frameId = 'ticket';
18
28
  /**
@@ -60,9 +70,9 @@ export class TicketDetailScreen extends AsyncScreen {
60
70
  effective(context) {
61
71
  return [
62
72
  ...TICKET_TABS,
63
- ...this.matched(context).map((panel) => ({
64
- id: panel.id,
65
- label: panel.title,
73
+ ...this.matched(context).map((contribution) => ({
74
+ id: contribution.id,
75
+ label: contribution.title,
66
76
  })),
67
77
  ];
68
78
  }
@@ -77,7 +87,10 @@ export class TicketDetailScreen extends AsyncScreen {
77
87
  */
78
88
  matched(context) {
79
89
  const query = context?.panels ?? this.panelQuery;
80
- return (query?.('panel', {
90
+ // GAIA-475 — `'tab'`, so BOTH kinds reach the bar in the one order this
91
+ // screen promises: load order from `addons[]`. Asking for panels and then
92
+ // for tables would re-sort the bar by KIND behind the operator's back.
93
+ return (query?.('tab', {
81
94
  routeId: this.id,
82
95
  params: context?.params ?? this.panelParams,
83
96
  }) ?? []);
@@ -99,8 +112,60 @@ export class TicketDetailScreen extends AsyncScreen {
99
112
  return true;
100
113
  }
101
114
  reload(context) {
115
+ // GAIA-475 A5 — bump BEFORE the retry, so the next render a contribution sees
116
+ // already carries the new token and drops its cache on the same reload the
117
+ // operator asked for.
118
+ this.reloadToken += 1;
102
119
  this.retry(context);
103
120
  }
121
+ /**
122
+ * GAIA-475 criterion 24 — arm or cancel the table's self-refresh.
123
+ *
124
+ * Called wherever the OPEN TAB can change: `enter`, `selectTab`, and after an
125
+ * activation. A contributed table is a view of the machine rather than of the
126
+ * ticket — a Storybook started ten minutes into a run is not in any document
127
+ * the host fetched — so it is the one tab that has to re-read without being
128
+ * asked. The built-in tabs deliberately do not tick: their data comes from the
129
+ * API, and a timer on them would be a polling loop nobody asked for.
130
+ *
131
+ * What a tick does is the cheap half of `reload`: bump the token and redraw.
132
+ * It does NOT call `retry()`, so no HTTP request is made — the contribution
133
+ * re-reads its own source (the socket table, the state file) and everything
134
+ * the screen shows about the ticket is left exactly as it was.
135
+ */
136
+ syncTableTick(context) {
137
+ const wanted = this.activeContribution(context)?.type === 'table';
138
+ if (!wanted) {
139
+ this.cancelTableTick();
140
+ return;
141
+ }
142
+ if (this.tableTick)
143
+ return;
144
+ this.armTableTick(context);
145
+ }
146
+ armTableTick(context) {
147
+ const debounce = context.options?.debounce ?? timerDebounce;
148
+ this.tableTick = debounce(() => {
149
+ this.tableTick = undefined;
150
+ // Still the open tab? The operator may have moved on between the arming
151
+ // and the fire, and a tick that re-arms itself off a stale tab would
152
+ // outlive the tab that wanted it.
153
+ if (this.activeContribution(context)?.type !== 'table')
154
+ return;
155
+ this.reloadToken += 1;
156
+ this.armTableTick(context);
157
+ context.requestDraw();
158
+ }, TABLE_REFRESH_MS);
159
+ }
160
+ cancelTableTick() {
161
+ this.tableTick?.();
162
+ this.tableTick = undefined;
163
+ }
164
+ leave() {
165
+ // The screen is gone; its timer may not outlive it.
166
+ this.cancelTableTick();
167
+ super.leave();
168
+ }
104
169
  static route = {
105
170
  id: 'ticket.detail',
106
171
  path: '/tickets/:ticketId',
@@ -138,6 +203,19 @@ export class TicketDetailScreen extends AsyncScreen {
138
203
  */
139
204
  panelQuery;
140
205
  panelParams = {};
206
+ /** GAIA-475 — the table rows whose `onActivate` is in flight. */
207
+ activatingRows = new Set();
208
+ /** GAIA-475 A5 — bumped on every reload, handed to a table so it can drop its
209
+ * cache. A counter, not a clock. */
210
+ reloadToken = 0;
211
+ /**
212
+ * GAIA-475 criterion 24 — the self-refresh, as its cancel function.
213
+ *
214
+ * Set while a contributed TABLE is the open tab, `undefined` otherwise, so the
215
+ * field's presence IS "a tick is armed" and there is no second flag to
216
+ * disagree with it.
217
+ */
218
+ tableTick;
141
219
  /** GAIA-326 — the last copy's outcome, shown in the footer. Empty until the
142
220
  * operator asks for one; see `copyOpenTicket`. */
143
221
  status = '';
@@ -176,12 +254,17 @@ export class TicketDetailScreen extends AsyncScreen {
176
254
  const document = context.contexts.get(ticketDetailInputContext).document;
177
255
  this.replaceReady(document);
178
256
  this.initialize(document);
257
+ // GAIA-475 criterion 24 — `initialize` may have put the screen straight
258
+ // onto a contributed tab, and a re-entry after a reload lands here too, so
259
+ // the tick is resolved on this path as well as on `selectTab`.
260
+ this.syncTableTick(context);
179
261
  context.requestDraw();
180
262
  return;
181
263
  }
182
264
  super.enter(context);
183
265
  this.viewMode = viewMode;
184
266
  this.requestedTab = requestedTab;
267
+ this.syncTableTick(context);
185
268
  }
186
269
  setLabels(labels) {
187
270
  this.labels = labels;
@@ -192,6 +275,9 @@ export class TicketDetailScreen extends AsyncScreen {
192
275
  return false;
193
276
  this.activeTab = next;
194
277
  this.selectedEntry = 0;
278
+ // GAIA-475 criterion 24 — the open tab just changed, so the tick follows it
279
+ // on or off. This is the only place the active index moves.
280
+ this.syncTableTick(context);
195
281
  context.requestDraw();
196
282
  return true;
197
283
  }
@@ -204,10 +290,20 @@ export class TicketDetailScreen extends AsyncScreen {
204
290
  context.requestDraw();
205
291
  return true;
206
292
  }
207
- activate(index, context) {
293
+ activate(index, context, intent) {
208
294
  const document = this.document;
209
295
  if (!document)
210
296
  return false;
297
+ // GAIA-475 — a contributed TABLE claims the key before the expansion toggle,
298
+ // and instead of it: a table row has nothing to expand. The activation is
299
+ // async and fire-and-forget from the key path; `activateTableRow` owns the
300
+ // busy set, the status line and the redraw discipline. `intent` is the
301
+ // start/stop distinction, forwarded to the contribution.
302
+ const contribution = this.activeContribution(context);
303
+ if (contribution?.type === 'table') {
304
+ void this.activateTableRow(contribution, index, context, intent);
305
+ return true;
306
+ }
211
307
  if (TICKET_SECTIONS[this.activeTab]?.id === 'runs') {
212
308
  const run = ticketRunRows(document)[index];
213
309
  if (!run?.id || !context.navigate)
@@ -299,19 +395,25 @@ export class TicketDetailScreen extends AsyncScreen {
299
395
  const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
300
396
  return [
301
397
  ...detailLines(document, this.labels).lines,
302
- ...this.matched(context).flatMap((panel) => flattenSections(this.panelSections(panel, context, {
303
- expanded: new Set(),
304
- nowSeconds,
305
- }).map((section) => ({
306
- heading: section.heading,
307
- // Lifted to rows and read back as text, because since GAIA-372 D3
308
- // a collapsed rule is a function over RENDERED LINES. Lifting is
309
- // not parsing: a captured log reaches the pipe as the bytes it
310
- // holds, exactly as it reaches the tab.
311
- lines: section.expandable
312
- ? firstLineSummary(section.lines.map(plainLine)).map((line) => line.text)
313
- : [...section.lines],
314
- }))).lines),
398
+ ...this.matched(context).flatMap((contribution) => contribution.type === 'table'
399
+ ? // GAIA-475 A4 — the table's own text, under its title as the heading,
400
+ // through the list serialisation every built-in list prints through.
401
+ // No `firstLineSummary` here and nothing collapsible: a table has no
402
+ // body behind a caret, so there is nothing for the pipe to hold back.
403
+ this.tableLines(contribution, context, nowSeconds)
404
+ : flattenSections(this.panelSections(contribution, context, {
405
+ expanded: new Set(),
406
+ nowSeconds,
407
+ }).map((section) => ({
408
+ heading: section.heading,
409
+ // Lifted to rows and read back as text, because since GAIA-372 D3
410
+ // a collapsed rule is a function over RENDERED LINES. Lifting is
411
+ // not parsing: a captured log reaches the pipe as the bytes it
412
+ // holds, exactly as it reaches the tab.
413
+ lines: section.expandable
414
+ ? firstLineSummary(section.lines.map(plainLine)).map((line) => line.text)
415
+ : [...section.lines],
416
+ }))).lines),
315
417
  ];
316
418
  }
317
419
  handleInput(input, context) {
@@ -335,6 +437,15 @@ export class TicketDetailScreen extends AsyncScreen {
335
437
  return true;
336
438
  }
337
439
  const view = this.currentView(context);
440
+ // GAIA-475 UX — `s` STARTS and `x` STOPS the selected row's action on a
441
+ // TABLE tab, two distinct keys because Enter reads as "expand" on a detail
442
+ // screen and one key doing both is a confusing toggle. Only when a table is
443
+ // the active contribution, so `s`/`x` are untouched everywhere else.
444
+ const onTable = this.activeContribution(context)?.type === 'table';
445
+ if (onTable && input === 's' && view.selectable > 0)
446
+ return this.activate(this.selectedEntry, context, 'activate');
447
+ if (onTable && input === 'x' && view.selectable > 0)
448
+ return this.activate(this.selectedEntry, context, 'deactivate');
338
449
  if ((input === 'enter' || input === ' ') && view.selectable > 0)
339
450
  return this.activate(this.selectedEntry, context);
340
451
  // GAIA-369 AC-7 — "HANDLED" MEANS THE SELECTION MOVED, and that is the whole
@@ -511,10 +622,19 @@ export class TicketDetailScreen extends AsyncScreen {
511
622
  actionItem('detail-arrows', '↑↓', arrows),
512
623
  ];
513
624
  if (view.selectable > 0) {
514
- actions.push(this.activeSectionId(context) === 'runs' &&
515
- this.activeTab < TICKET_TABS.length
516
- ? actionItem('detail-enter', 'Enter', 'open run')
517
- : actionItem('detail-enter', 'Enter/Space', 'expand'));
625
+ // GAIA-475 — a table tab offers its action under a dedicated key `s`, so
626
+ // the footer names it rather than leaving the operator to guess Enter does
627
+ // something other than expand.
628
+ if (this.activeContribution(context)?.type === 'table') {
629
+ actions.push(actionItem('detail-share', 's', 'start'));
630
+ actions.push(actionItem('detail-unshare', 'x', 'stop'));
631
+ }
632
+ else {
633
+ actions.push(this.activeSectionId(context) === 'runs' &&
634
+ this.activeTab < TICKET_TABS.length
635
+ ? actionItem('detail-enter', 'Enter', 'open run')
636
+ : actionItem('detail-enter', 'Enter/Space', 'expand'));
637
+ }
518
638
  }
519
639
  return actions;
520
640
  }
@@ -533,6 +653,9 @@ export class TicketDetailScreen extends AsyncScreen {
533
653
  this.status = '';
534
654
  this.panelQuery = undefined;
535
655
  this.panelParams = {};
656
+ this.activatingRows = new Set();
657
+ this.reloadToken = 0;
658
+ this.cancelTableTick();
536
659
  }
537
660
  initialize(document) {
538
661
  const key = document.data?.id ?? document;
@@ -549,27 +672,31 @@ export class TicketDetailScreen extends AsyncScreen {
549
672
  const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
550
673
  const expanded = this.expandedByTab.get(this.activeSectionId(context)) ??
551
674
  new Set();
552
- // GAIA-373 — past the built-ins, the active index names a PANEL, and its
553
- // sections are resolved here rather than inside the component: the panel is
554
- // the screen's dependency, and `tabViewTree` stays a pure function of rows.
555
- const panel = this.activePanel(context);
675
+ // GAIA-373 — past the built-ins, the active index names a CONTRIBUTION, and
676
+ // its rows are resolved here rather than inside the component: the
677
+ // contribution is the screen's dependency, and `tabViewTree` stays a pure
678
+ // function of rows.
679
+ //
680
+ // GAIA-475 — two kinds now, and the branch is on the contribution's own
681
+ // `type` rather than on anything the screen tracks separately, so the two
682
+ // cannot disagree about which tab is open.
683
+ const contribution = this.activeContribution(context);
684
+ const render = { expanded, nowSeconds };
556
685
  return tabViewTree(this.document ?? {}, this.activeTab, {
557
686
  expanded,
558
687
  nowSeconds,
559
688
  selectedIndex: this.selectedEntry,
560
689
  ...(this.labels ? { labels: this.labels } : {}),
561
- ...(panel
562
- ? {
563
- panelSections: this.panelSections(panel, context, {
564
- expanded,
565
- nowSeconds,
566
- }),
567
- }
690
+ ...(contribution?.type === 'panel'
691
+ ? { panelSections: this.panelSections(contribution, context, render) }
692
+ : {}),
693
+ ...(contribution?.type === 'table'
694
+ ? this.tableView(contribution, context, render)
568
695
  : {}),
569
696
  });
570
697
  }
571
- /** The panel the active index names, or none while a built-in is open. */
572
- activePanel(context) {
698
+ /** The contribution the active index names, or none while a built-in is open. */
699
+ activeContribution(context) {
573
700
  return this.activeTab < TICKET_TABS.length
574
701
  ? undefined
575
702
  : this.matched(context)[this.activeTab - TICKET_TABS.length];
@@ -600,18 +727,146 @@ export class TicketDetailScreen extends AsyncScreen {
600
727
  * untouched, so `emptyMessage` still means "nothing to show", never "it
601
728
  * broke".
602
729
  */
730
+ /**
731
+ * GAIA-475 A3 — activate one table row, following the conductor screen's shape.
732
+ *
733
+ * Mark busy, redraw, await, record the outcome as the footer status, clear busy
734
+ * in a `finally`, redraw. The busy set is what makes a second Enter a no-op
735
+ * while the first is in flight (US-12) and what a cell reads through
736
+ * `PanelRender.activating` to show the work.
737
+ *
738
+ * The row is picked from the SAME guarded resolution the render uses
739
+ * (`tableView`), so the row `onActivate` receives is exactly the row the
740
+ * operator saw; a table whose `rows()` throws resolves to the error branch and
741
+ * simply has no row to activate.
742
+ */
743
+ async activateTableRow(table, index, context, intent) {
744
+ const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
745
+ const resolved = this.tableView(table, context, {
746
+ expanded: new Set(),
747
+ nowSeconds,
748
+ });
749
+ if ('panelSections' in resolved)
750
+ return;
751
+ const row = resolved.panelTable.rows[index];
752
+ if (!row || !table.onActivate || this.activatingRows.has(row.id))
753
+ return;
754
+ this.activatingRows.add(row.id);
755
+ context.requestDraw();
756
+ try {
757
+ const result = await table.onActivate(row, this.panelRender(context, { expanded: new Set(), nowSeconds }, intent));
758
+ this.status = result.message ?? '';
759
+ }
760
+ catch (caught) {
761
+ this.status = `${table.id} action failed: ${caught instanceof Error ? caught.message : String(caught)}`;
762
+ }
763
+ finally {
764
+ this.activatingRows.delete(row.id);
765
+ // GAIA-475 UX — BUMP the reload token so the contribution drops its
766
+ // discovery/state cache and the row visibly updates on this redraw: the
767
+ // tunnel just wrote the state file, and without the bump the cockpit would
768
+ // read a stale snapshot and the SHARED column would stay empty until the
769
+ // next auto-reload. This is what makes the async bring-up feel synchronous
770
+ // — busy `…` while it runs, the URL the instant it is ready.
771
+ this.reloadToken += 1;
772
+ context.requestDraw();
773
+ }
774
+ }
775
+ /** Tests and the busy indicator: the row ids whose activation is in flight. */
776
+ activatingRowIds() {
777
+ return [...this.activatingRows];
778
+ }
779
+ /**
780
+ * One contributed table's columns and rows — `panelSections`' twin (GAIA-475).
781
+ *
782
+ * Returns the OPTION BAG `tabViewTree` takes rather than the data directly,
783
+ * because a failure has to change WHICH branch renders: there is no such thing
784
+ * as a table of an error. On a throw this degrades to `panelSections`, so a
785
+ * broken table produces the same named, readable failure row a broken panel
786
+ * does — one rendering of "this addon broke", not two.
787
+ *
788
+ * The same chokepoint argument applies here as there, and more sharply: both
789
+ * `columns()` and `rows()` are contributed code, both run on the draw path, and
790
+ * the draw path is not uniformly guarded (`Kernel/tui-kernel.ts` wires `draw`
791
+ * straight onto the resize and mouse-scroll events). A throw out of either one
792
+ * would freeze or crash the cockpit.
793
+ *
794
+ * `noun` replaces `/` with `-` because the id is org-prefixed by convention
795
+ * (`gaia/ports`) and it becomes a node id prefix here — `rowNodeId(noun, …)` is
796
+ * what a click resolves through, so the separator has to survive that round
797
+ * trip intact.
798
+ */
799
+ tableView(table, context, render) {
800
+ const ctx = this.panelRender(context, render);
801
+ try {
802
+ return {
803
+ panelTable: {
804
+ noun: table.id.replace(/\//g, '-'),
805
+ columns: table.columns(ctx),
806
+ rows: table.rows(this.document ?? {}, ctx),
807
+ emptyMessage: table.emptyMessage ?? 'Nothing to show.',
808
+ },
809
+ };
810
+ }
811
+ catch (caught) {
812
+ return {
813
+ panelSections: [
814
+ {
815
+ heading: table.title || table.id,
816
+ lines: [
817
+ `${table.id} failed to render: ` +
818
+ `${caught instanceof Error ? caught.message : String(caught)}`,
819
+ ],
820
+ },
821
+ ],
822
+ };
823
+ }
824
+ }
825
+ /**
826
+ * One contributed table as the pipe's lines — the SAME resolution the draw
827
+ * path uses, so the two renderings cannot disagree about what the table holds.
828
+ *
829
+ * Routed through `tableView`, which means the error boundary covers the pipe
830
+ * too: a throwing table prints its named failure row here exactly as it draws
831
+ * one on screen.
832
+ */
833
+ tableLines(table, context, nowSeconds) {
834
+ const resolved = this.tableView(table, context, {
835
+ expanded: new Set(),
836
+ nowSeconds,
837
+ });
838
+ if ('panelSections' in resolved) {
839
+ return flattenSections(resolved.panelSections.map((section) => ({
840
+ heading: section.heading,
841
+ lines: [...section.lines],
842
+ }))).lines;
843
+ }
844
+ return flattenSections([
845
+ {
846
+ heading: table.title || table.id,
847
+ lines: panelTableLines(resolved.panelTable),
848
+ },
849
+ ]).lines;
850
+ }
851
+ /** The `PanelRender` a contribution is handed — one construction, two kinds. */
852
+ panelRender(context, render, intent) {
853
+ return {
854
+ routeId: this.id,
855
+ params: context.params ?? {},
856
+ expanded: render.expanded,
857
+ nowSeconds: render.nowSeconds,
858
+ activating: this.activatingRows,
859
+ reloadToken: this.reloadToken,
860
+ ...(intent ? { intent } : {}),
861
+ ...(context.options?.cwd !== undefined
862
+ ? { cwd: context.options.cwd }
863
+ : {}),
864
+ };
865
+ }
603
866
  panelSections(panel, context, render) {
604
867
  let sections;
605
868
  try {
606
- sections = panel.sections(this.document ?? {}, {
607
- routeId: this.id,
608
- params: context.params ?? {},
609
- expanded: render.expanded,
610
- nowSeconds: render.nowSeconds,
611
- ...(context.options?.cwd !== undefined
612
- ? { cwd: context.options.cwd }
613
- : {}),
614
- });
869
+ sections = panel.sections(this.document ?? {}, this.panelRender(context, render));
615
870
  }
616
871
  catch (caught) {
617
872
  return [
@@ -18,6 +18,48 @@ export interface PanelRender extends PanelMatch {
18
18
  readonly nowSeconds: number;
19
19
  /** The checkout the cockpit was started in (`TuiOptions.cwd`). */
20
20
  readonly cwd?: string | undefined;
21
+ /**
22
+ * GAIA-475 — the ids of the rows whose `onActivate` is in flight right now.
23
+ *
24
+ * The HOST owns this set — it adds a row on activation and clears it in a
25
+ * `finally` — and hands it to the contribution's `columns`/`rows` so a cell can
26
+ * say "…" while the work runs (US-12). Absent for a `panel`, which has no
27
+ * activation; empty when nothing is in flight.
28
+ */
29
+ readonly activating?: ReadonlySet<string>;
30
+ /**
31
+ * GAIA-475 A5 — a value that CHANGES whenever the host reloads.
32
+ *
33
+ * A contribution holding a short-lived cache invalidates it by comparing this
34
+ * against the token it last computed under. Without it the existing reload key
35
+ * refreshes the ticket document and leaves a cached list untouched — a keypress
36
+ * that visibly does nothing. It is a monotone counter, not a timestamp, so it
37
+ * carries no clock and two reloads are always distinguishable.
38
+ */
39
+ readonly reloadToken?: number;
40
+ /**
41
+ * GAIA-475 UX — which INTENT triggered this activation, when the host offers
42
+ * two keys for it.
43
+ *
44
+ * A table with a reversible action (a tunnel: up and down) reads better under
45
+ * two distinct keys than one toggle — `s` to start, `x` to stop — because the
46
+ * same key doing both is the UX the operator flagged. The host sets this from
47
+ * the key; the contribution's `onActivate` interprets it. Absent for a single
48
+ * `Enter`, which the contribution is free to treat as a toggle. The core stays
49
+ * semantics-free: it forwards the intent, it does not know what start means.
50
+ */
51
+ readonly intent?: 'activate' | 'deactivate';
52
+ }
53
+ /**
54
+ * GAIA-475 — what a table's `onActivate` reports back.
55
+ *
56
+ * A footer line and nothing more: the row's own state change is observed on the
57
+ * next render (the state file the action wrote, re-read), so the result does not
58
+ * carry a new row — that would be a second source of truth for what the next
59
+ * `rows()` already answers.
60
+ */
61
+ export interface ActivationResult {
62
+ readonly message?: string;
21
63
  }
22
64
  /**
23
65
  * A panel: a section MODE of a host screen.
@@ -53,6 +95,94 @@ export interface UiScreen {
53
95
  readonly route: RouteMetadata;
54
96
  screen(): Screen;
55
97
  }
56
- export type UiContribution = UiPanel | UiScreen;
57
- /** The host's read: contributions of one type whose `match` accepts this place. */
58
- export type PanelQuery = (type: 'panel', ctx: PanelMatch) => readonly UiPanel[];
98
+ /**
99
+ * One column of a contributed table: its LAYOUT and an accessor, never a renderer.
100
+ *
101
+ * Deliberately the subset of `ListColumn` (`Entity/collection.ts`) that a
102
+ * contribution can answer for. `key`/`label`/`width`/`min`/`max`/`grow` carry the
103
+ * same meanings there — width 0 is the trailing column that takes the rest of the
104
+ * row — so the host maps one onto the other without translating anything.
105
+ *
106
+ * Two members of `ListColumn` are deliberately absent. `fields` names the JSON:API
107
+ * attributes a sparse fieldset must request, and a contribution has no such query:
108
+ * it hands over finished rows. And `get` here takes the row ALONE, where the host's
109
+ * takes a `Ctx` too — a contribution's context is the `PanelRender` it already
110
+ * received in `columns()`, so a second channel would be a way to disagree with it.
111
+ */
112
+ export interface PanelColumn<Row> {
113
+ /** Stable identity for the column; not rendered, and the id its cells carry. */
114
+ readonly key: string;
115
+ readonly label: string;
116
+ /** Display columns to lay out; **0** is the trailing column. */
117
+ readonly width: number;
118
+ readonly min?: number;
119
+ readonly max?: number;
120
+ readonly grow?: number;
121
+ get(row: Row): unknown;
122
+ }
123
+ /**
124
+ * A table: a contributed LIST, rendered by the host's own list machinery.
125
+ *
126
+ * The second contribution kind (GAIA-475 A1), and the reason it is data rather
127
+ * than a render tree: the host already owns TWO serialisations of a list — one
128
+ * for the terminal and one for piped output — so a contribution that returned a
129
+ * tree would force a second rendering into existence and leave the piped path to
130
+ * rot. `columns` and `rows` are all the host needs to drive both.
131
+ *
132
+ * `Row extends { id: string }` because row identity is not optional here: it keys
133
+ * the node a selection lands on, exactly as `ListTable` requires of every list the
134
+ * cockpit already renders.
135
+ */
136
+ export interface UiTable<Row extends {
137
+ id: string;
138
+ } = {
139
+ id: string;
140
+ }, Doc = unknown> {
141
+ readonly type: 'table';
142
+ /** Org-prefixed and globally unique, e.g. `gaia/ports`. */
143
+ readonly id: string;
144
+ /** The label the tab bar shows. */
145
+ readonly title: string;
146
+ match(ctx: PanelMatch): boolean;
147
+ columns(ctx: PanelRender): readonly PanelColumn<Row>[];
148
+ rows(doc: Doc, ctx: PanelRender): readonly Row[];
149
+ /**
150
+ * GAIA-475 A2 — what Enter on a row does. Optional: a table with no action is
151
+ * a read-only list, and Enter on it does nothing rather than toggling anything.
152
+ *
153
+ * The host consults this BEFORE its own expansion toggle and, for a table,
154
+ * instead of it — a table row has no body behind a caret. The host wraps the
155
+ * call: a rejection becomes a footer message, never a dead cockpit, and the row
156
+ * is marked in `PanelRender.activating` for the duration.
157
+ */
158
+ onActivate?(row: Row, ctx: PanelRender): Promise<ActivationResult>;
159
+ /** Shown when `rows` comes back empty. */
160
+ readonly emptyMessage?: string;
161
+ }
162
+ export type UiContribution = UiPanel | UiTable | UiScreen;
163
+ /**
164
+ * The two kinds that render as a TAB of a host screen.
165
+ *
166
+ * `UiScreen` is deliberately absent: it is a destination, not a section mode, and
167
+ * `store.ts` refuses it at registration anyway.
168
+ */
169
+ export type UiTab = UiPanel | UiTable;
170
+ /**
171
+ * The host's read: contributions of one type whose `match` accepts this place.
172
+ *
173
+ * Overloaded rather than widened, so the TYPE ARGUMENT decides the result type.
174
+ * That is what keeps a table out of a `sections`-shaped renderer at compile time
175
+ * instead of by each caller's discipline.
176
+ *
177
+ * `'tab'` is the third reading and not a convenience: a screen's tab order is
178
+ * LOAD ORDER (`ticket-detail.ts` › `effective`), so asking for panels and then
179
+ * for tables would quietly re-sort the bar BY KIND and an addon contributing both
180
+ * would always find its table behind every other addon's panel. One query keeps
181
+ * the one order the screen promises; `'panel'`/`'table'` narrow when a caller
182
+ * genuinely wants one kind.
183
+ */
184
+ export interface PanelQuery {
185
+ (type: 'panel', ctx: PanelMatch): readonly UiPanel[];
186
+ (type: 'table', ctx: PanelMatch): readonly UiTable[];
187
+ (type: 'tab', ctx: PanelMatch): readonly UiTab[];
188
+ }
@@ -39,6 +39,14 @@ export function resetCollector() {
39
39
  collected.length = 0;
40
40
  }
41
41
  export function createPanelQuery(contributions) {
42
- const panels = contributions.filter((c) => c.type === 'panel');
43
- return (_type, ctx) => panels.filter((panel) => panel.match(ctx));
42
+ // ONE list, filtered three ways, so the pools cannot drift out of step with
43
+ // each other or with the order they were registered in. `tabs` keeps that
44
+ // registration order across both kinds — the property the screen's tab bar is
45
+ // specified in terms of — and the two narrow pools are derived FROM it rather
46
+ // than collected separately.
47
+ const tabs = contributions.filter((c) => c.type === 'panel' || c.type === 'table');
48
+ const panels = tabs.filter((c) => c.type === 'panel');
49
+ const tables = tabs.filter((c) => c.type === 'table');
50
+ const pool = { panel: panels, table: tables, tab: tabs };
51
+ return ((type, ctx) => pool[type].filter((c) => c.match(ctx)));
44
52
  }
package/dist/src/ui.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export type { PanelMatch, PanelQuery, PanelRender, PanelSection, UiContribution, UiPanel, UiScreen, } from './Extension/contract.js';
1
+ export type { ActivationResult, PanelColumn, PanelMatch, PanelQuery, PanelRender, PanelSection, UiContribution, UiPanel, UiScreen, UiTable, } from './Extension/contract.js';
2
2
  export { type ContributionApi, register } from './Extension/store.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-gaia-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,10 +22,10 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@opentui/core": "0.5.1",
25
- "bun": "1.3.14",
25
+ "bun": "1.4.0",
26
26
  "drupal-jsonapi-params": "^3.0.1"
27
27
  },
28
28
  "peerDependencies": {
29
- "@gaia-ai/core": "^0.12.0"
29
+ "@gaia-ai/core": "^0.13.0"
30
30
  }
31
31
  }