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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/src/Agent/launch.d.ts +4 -4
  2. package/dist/src/Agent/launch.js +14 -7
  3. package/dist/src/Component/stats-panel/index.d.ts +13 -2
  4. package/dist/src/Component/stats-panel/index.js +28 -18
  5. package/dist/src/Component/top-tabs/index.d.ts +6 -0
  6. package/dist/src/Component/top-tabs/index.js +15 -6
  7. package/dist/src/Entity/collection.d.ts +98 -0
  8. package/dist/src/Entity/collection.js +46 -0
  9. package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
  10. package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
  11. package/dist/src/Entity/conductor/Data/conductors.d.ts +57 -0
  12. package/dist/src/Entity/conductor/Data/conductors.js +67 -0
  13. package/dist/src/Entity/conductor/Data/liveness.d.ts +2 -1
  14. package/dist/src/Entity/conductor/Data/liveness.js +12 -16
  15. package/dist/src/Entity/conductor/Data/log-tail.d.ts +25 -0
  16. package/dist/src/Entity/conductor/Data/log-tail.js +133 -0
  17. package/dist/src/Entity/conductor/Data/own-conductor.d.ts +30 -0
  18. package/dist/src/Entity/conductor/Data/own-conductor.js +73 -0
  19. package/dist/src/Entity/conductor/Data/process.d.ts +78 -0
  20. package/dist/src/Entity/conductor/Data/process.js +101 -0
  21. package/dist/src/Entity/conductor/Screen/conductors.d.ts +42 -0
  22. package/dist/src/Entity/conductor/Screen/conductors.js +200 -0
  23. package/dist/src/Entity/cycle.d.ts +18 -0
  24. package/dist/src/Entity/cycle.js +30 -0
  25. package/dist/src/Entity/project/Data/project.d.ts +8 -0
  26. package/dist/src/Entity/project/Data/project.js +12 -0
  27. package/dist/src/Entity/project/Data/projects.d.ts +3 -0
  28. package/dist/src/Entity/project/Data/projects.js +3 -0
  29. package/dist/src/Entity/project/Data/with-tickets.d.ts +5 -0
  30. package/dist/src/Entity/project/Data/with-tickets.js +15 -13
  31. package/dist/src/Entity/project/Screen/project-picker.d.ts +36 -0
  32. package/dist/src/Entity/project/Screen/project-picker.js +90 -0
  33. package/dist/src/Entity/project/Screen/projects-screen.d.ts +40 -0
  34. package/dist/src/Entity/project/Screen/projects-screen.js +103 -0
  35. package/dist/src/Entity/run/Component/run-table/index.d.ts +5 -7
  36. package/dist/src/Entity/run/Component/run-table/index.js +9 -18
  37. package/dist/src/Entity/run/Data/query.d.ts +22 -0
  38. package/dist/src/Entity/run/Data/query.js +41 -0
  39. package/dist/src/Entity/run/Data/runs.d.ts +85 -4
  40. package/dist/src/Entity/run/Data/runs.js +143 -27
  41. package/dist/src/Entity/run/Screen/run-detail.d.ts +25 -0
  42. package/dist/src/Entity/run/Screen/run-detail.js +51 -0
  43. package/dist/src/Entity/run/Screen/runs.d.ts +82 -0
  44. package/dist/src/Entity/run/Screen/runs.js +169 -0
  45. package/dist/src/Entity/statistics/Screen/dashboard.d.ts +42 -0
  46. package/dist/src/Entity/statistics/Screen/dashboard.js +177 -0
  47. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +10 -0
  48. package/dist/src/Entity/ticket/Component/create-form/index.js +4 -0
  49. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +3 -2
  50. package/dist/src/Entity/ticket/Component/edit-form/index.js +30 -8
  51. package/dist/src/Entity/ticket/Component/tab-bar/index.js +6 -0
  52. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +1 -1
  53. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +33 -2
  54. package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +4 -2
  55. package/dist/src/Entity/ticket/Component/ticket-table/index.js +8 -12
  56. package/dist/src/Entity/ticket/Data/parents.d.ts +13 -1
  57. package/dist/src/Entity/ticket/Data/parents.js +64 -12
  58. package/dist/src/Entity/ticket/Data/query.d.ts +44 -7
  59. package/dist/src/Entity/ticket/Data/query.js +60 -26
  60. package/dist/src/Entity/ticket/Data/tickets.d.ts +105 -4
  61. package/dist/src/Entity/ticket/Data/tickets.js +164 -12
  62. package/dist/src/Entity/ticket/Data/workflow.d.ts +1 -5
  63. package/dist/src/Entity/ticket/Data/workflow.js +5 -13
  64. package/dist/src/Entity/ticket/Data/write.d.ts +32 -0
  65. package/dist/src/Entity/ticket/Data/write.js +80 -0
  66. package/dist/src/Entity/ticket/Form/create-data.d.ts +1 -11
  67. package/dist/src/Entity/ticket/Form/create-data.js +7 -36
  68. package/dist/src/Entity/ticket/Form/create.d.ts +45 -5
  69. package/dist/src/Entity/ticket/Form/create.js +45 -8
  70. package/dist/src/Entity/ticket/Form/edit-data.d.ts +1 -1
  71. package/dist/src/Entity/ticket/Form/edit-data.js +4 -0
  72. package/dist/src/Entity/ticket/Form/edit.d.ts +79 -12
  73. package/dist/src/Entity/ticket/Form/edit.js +165 -18
  74. package/dist/src/Entity/ticket/Form/keys.d.ts +7 -0
  75. package/dist/src/Entity/ticket/Form/keys.js +7 -0
  76. package/dist/src/Entity/ticket/Form/parent-filter.d.ts +36 -0
  77. package/dist/src/Entity/ticket/Form/parent-filter.js +37 -0
  78. package/dist/src/Entity/ticket/Form/parent-source.d.ts +27 -0
  79. package/dist/src/Entity/ticket/Form/parent-source.js +56 -0
  80. package/dist/src/Entity/ticket/Form/who-filter.d.ts +33 -0
  81. package/dist/src/Entity/ticket/Form/who-filter.js +33 -0
  82. package/dist/src/Entity/ticket/Form/who-source.d.ts +18 -0
  83. package/dist/src/Entity/ticket/Form/who-source.js +38 -0
  84. package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +68 -0
  85. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +336 -0
  86. package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +81 -0
  87. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +200 -0
  88. package/dist/src/Entity/ticket/Screen/parent-filter-form.d.ts +27 -0
  89. package/dist/src/Entity/ticket/Screen/parent-filter-form.js +177 -0
  90. package/dist/src/Entity/ticket/Screen/ticket-agents.d.ts +45 -0
  91. package/dist/src/Entity/ticket/Screen/ticket-agents.js +135 -0
  92. package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +61 -0
  93. package/dist/src/Entity/ticket/Screen/ticket-detail.js +300 -0
  94. package/dist/src/Entity/ticket/Screen/tickets.d.ts +129 -0
  95. package/dist/src/Entity/ticket/Screen/tickets.js +507 -0
  96. package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +27 -0
  97. package/dist/src/Entity/ticket/Screen/who-filter-form.js +154 -0
  98. package/dist/src/Entity/user/Data/users.d.ts +17 -0
  99. package/dist/src/Entity/user/Data/users.js +66 -6
  100. package/dist/src/Form/field.d.ts +3 -1
  101. package/dist/src/Form/field.js +1 -0
  102. package/dist/src/Form/form.d.ts +1 -1
  103. package/dist/src/Form/form.js +61 -9
  104. package/dist/src/Form/keys.d.ts +5 -1
  105. package/dist/src/Form/keys.js +38 -5
  106. package/dist/src/Kernel/tui-kernel.d.ts +0 -10
  107. package/dist/src/Kernel/tui-kernel.js +250 -2384
  108. package/dist/src/Routing/context.d.ts +31 -0
  109. package/dist/src/Routing/context.js +52 -0
  110. package/dist/src/Routing/registry.d.ts +19 -0
  111. package/dist/src/Routing/registry.js +97 -0
  112. package/dist/src/Routing/route.d.ts +39 -0
  113. package/dist/src/Routing/route.js +1 -0
  114. package/dist/src/Routing/router.d.ts +33 -0
  115. package/dist/src/Routing/router.js +102 -0
  116. package/dist/src/Routing/routes.d.ts +17 -0
  117. package/dist/src/Routing/routes.js +57 -0
  118. package/dist/src/Screen/async-screen.d.ts +50 -0
  119. package/dist/src/Screen/async-screen.js +164 -0
  120. package/dist/src/Screen/list-screen.d.ts +158 -0
  121. package/dist/src/Screen/list-screen.js +237 -0
  122. package/dist/src/Screen/screen.d.ts +106 -0
  123. package/dist/src/Screen/screen.js +18 -0
  124. package/dist/src/Tui/controller.d.ts +13 -0
  125. package/dist/src/Tui/controller.js +1794 -0
  126. package/dist/src/Widget/text.js +49 -4
  127. package/dist/src/Widget/types.d.ts +2 -1
  128. package/dist/src/launcher.d.ts +9 -2
  129. package/dist/src/launcher.js +2 -1
  130. package/dist/src/lib/ansi.js +13 -5
  131. package/dist/src/lib/cursor.d.ts +31 -0
  132. package/dist/src/lib/cursor.js +45 -0
  133. package/dist/src/lib/format.d.ts +21 -0
  134. package/dist/src/lib/format.js +59 -0
  135. package/dist/src/lib/table.d.ts +37 -0
  136. package/dist/src/lib/table.js +20 -0
  137. package/dist/src/lib/width.d.ts +33 -1
  138. package/dist/src/lib/width.js +107 -12
  139. package/dist/src/plugin.js +19 -0
  140. package/dist/src/types.d.ts +26 -0
  141. package/dist/src/types.js +0 -4
  142. package/package.json +2 -2
@@ -3,58 +3,174 @@
3
3
  // (AC-5). Runs are scoped to the selected project via the nested filter
4
4
  // `ticket_id.project_id.id` (a project may span several conductors, so the
5
5
  // ticket→project relationship is the canonical scope, not `conductor_id`).
6
- import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
6
+ // The query itself lives in `./query.ts` (GAIA-282); `ticketRunRows` stays a
7
+ // no-request read of the ticket document (spec D7).
8
+ import { loadPage } from '../../collection.js';
9
+ import { LIST_PAGE_SIZE } from '../../cycle.js';
10
+ import { buildRunQueryParams } from './query.js';
11
+ import { deriveRunStatus, runStartedAge } from './status.js';
7
12
  /** Read a value as a string, or `undefined` when it is not one. */
8
13
  function str(value) {
9
14
  return typeof value === 'string' ? value : undefined;
10
15
  }
16
+ /** In the fieldset, or the sideloaded ticket stops resolving (spec fact 5). */
17
+ export const RUN_LIST_RELATIONSHIPS = ['ticket_id'];
18
+ /** Requested, rendered by no column: `closed` feeds the closed predicate in
19
+ * `RunsScreen.load`. */
20
+ const RUN_PREDICATE_ATTRIBUTES = ['closed'];
21
+ /**
22
+ * The run list's columns — RUN · TICKET · STATE · PHASE · AGENT · STARTED, each
23
+ * carrying its own fieldset entry (GAIA-294 AC 4).
24
+ *
25
+ * `Ctx` is `nowSeconds`: STATE is the derived liveness status and STARTED the
26
+ * relative start age, and both need a clock the row does not carry.
27
+ */
28
+ export const RUN_COLUMNS = [
29
+ {
30
+ key: 'run',
31
+ label: 'RUN',
32
+ width: 8,
33
+ fields: ['drupal_internal__id'],
34
+ get: (r) => `#${r.drupalId ?? r.id.slice(0, 6)}`,
35
+ },
36
+ {
37
+ key: 'ticket',
38
+ label: 'TICKET',
39
+ width: 10,
40
+ fields: ['ticket_id'],
41
+ get: (r) => r.ticketIdentifier,
42
+ },
43
+ {
44
+ // Three fields for one column: `deriveRunStatus` reads the run's own state
45
+ // plus its heartbeat and claim window.
46
+ key: 'state',
47
+ label: 'STATE',
48
+ width: 8,
49
+ fields: ['state', 'heartbeat', 'claim_expires_at'],
50
+ get: (r, nowSeconds) => deriveRunStatus(r, nowSeconds),
51
+ },
52
+ {
53
+ key: 'phase',
54
+ label: 'PHASE',
55
+ width: 10,
56
+ fields: ['state_at_start'],
57
+ get: (r) => r.phase,
58
+ },
59
+ {
60
+ key: 'agent',
61
+ label: 'AGENT',
62
+ width: 8,
63
+ fields: ['agent'],
64
+ get: (r) => r.agent,
65
+ },
66
+ {
67
+ // The trailing column, unpadded. `created` is `started_at`'s documented
68
+ // fallback for a run that never started.
69
+ key: 'started',
70
+ label: 'STARTED',
71
+ width: 0,
72
+ fields: ['started_at', 'created'],
73
+ get: (r, nowSeconds) => runStartedAge(r, nowSeconds),
74
+ },
75
+ ];
76
+ /**
77
+ * The attributes a run row needs (GAIA-292 D2), in the order the query string
78
+ * states them. `created` is a column field as `started_at`'s documented fallback
79
+ * for a run that never started, and `heartbeat` + `claim_expires_at` ride the STATE
80
+ * column because `deriveRunStatus` reads them.
81
+ *
82
+ * Membership is owned by the columns and proved exhaustive below; only the ORDER
83
+ * is declared, because `tests/Entity/run/Data/query.test.ts` pins the emitted
84
+ * `fields[gaia_run--gaia_run]=…` sequence and column order would hoist
85
+ * `heartbeat`/`claim_expires_at` to third and fourth (GAIA-294, behaviour-neutral
86
+ * to the bytes on the wire).
87
+ */
88
+ export const RUN_LIST_ATTRIBUTES = [
89
+ 'drupal_internal__id',
90
+ 'state',
91
+ 'state_at_start',
92
+ 'agent',
93
+ 'started_at',
94
+ 'created',
95
+ 'heartbeat',
96
+ 'claim_expires_at',
97
+ 'closed',
98
+ ];
99
+ /** What `addFields('gaia_run--gaia_run', …)` takes. */
100
+ export const RUN_LIST_FIELDS = [
101
+ ...RUN_LIST_ATTRIBUTES,
102
+ ...RUN_LIST_RELATIONSHIPS,
103
+ ];
104
+ /**
105
+ * The sideloaded ticket renders ONE column: its identifier.
106
+ *
107
+ * Measured (GAIA-292 spec fact 4): without this, a 37-row run page shipped
108
+ * 191.4 KB of `included` — 12 whole ticket resources with their descriptions —
109
+ * out of 254.7 KB total, and took 4732 ms.
110
+ */
111
+ export const RUN_TICKET_INCLUDE_FIELDS = ['identifier'];
11
112
  /**
12
113
  * Map a RAW JSON:API `data[]` entry (from `client.get`) to a run row, resolving
13
114
  * the ticket identifier from the sideloaded includes indexed by id. Mirrors
14
115
  * `mapTicketData` — the only I/O is upstream in {@link loadProjectRuns}.
116
+ *
117
+ * Every attribute and relationship is read through an accessor keyed on the
118
+ * fieldset, so reading one the list does not request is a compile error (AC-5).
15
119
  */
16
120
  export function mapRunData(item, ticketsById) {
17
121
  const a = item.attributes ?? {};
18
- const ticketRel = item.relationships?.ticket_id?.data;
122
+ const text = (key) => typeof a[key] === 'string' ? a[key] : undefined;
123
+ const raw = (key) => a[key];
124
+ const rel = (key) => item.relationships?.[key]?.data;
125
+ const ticketRel = rel('ticket_id');
19
126
  const ticketId = Array.isArray(ticketRel)
20
127
  ? (ticketRel[0]?.id ?? null)
21
128
  : (ticketRel?.id ?? null);
22
129
  const ticket = ticketId ? ticketsById.get(ticketId) : undefined;
23
- const drupalId = typeof a.drupal_internal__id === 'number' ||
24
- typeof a.drupal_internal__id === 'string'
25
- ? a.drupal_internal__id
130
+ const internal = raw('drupal_internal__id');
131
+ const drupalId = typeof internal === 'number' || typeof internal === 'string'
132
+ ? internal
26
133
  : null;
27
134
  return {
28
135
  id: item.id ?? '',
29
136
  drupalId,
30
137
  ticketId,
31
138
  ticketIdentifier: str(ticket?.attributes?.identifier) ?? (ticketId ? ticketId : '—'),
32
- state: str(a.state) ?? 'unknown',
33
- phase: str(a.state_at_start) ?? '—',
34
- agent: str(a.agent) ?? '—',
35
- startedAt: str(a.started_at) ?? str(a.created) ?? null,
36
- heartbeat: str(a.heartbeat) ?? null,
37
- claimExpiresAt: str(a.claim_expires_at) ?? null,
139
+ state: text('state') ?? 'unknown',
140
+ phase: text('state_at_start') ?? '—',
141
+ agent: text('agent') ?? '—',
142
+ startedAt: text('started_at') ?? text('created') ?? null,
143
+ heartbeat: text('heartbeat') ?? null,
144
+ claimExpiresAt: text('claim_expires_at') ?? null,
145
+ closed: typeof raw('closed') === 'boolean' ? raw('closed') : false,
38
146
  };
39
147
  }
40
148
  /**
41
- * List the `gaia_run` entities of a project, most-recently-started first.
42
- * Filtered by `ticket_id.project_id.id`, includes `ticket_id`, sorts
43
- * `started_at` DESC, pages 50 (deeper history via offset is a later follow-up).
149
+ * List the `gaia_run` entities of a project under the given criteria,
150
+ * most-recently-started first — ONE page of them. The query (scope, include,
151
+ * fieldsets, sort, closed filter) is built by {@link buildRunQueryParams}; the
152
+ * page (size + index) is the caller's, and `hasNext` comes from the document's
153
+ * `links.next`.
44
154
  */
45
- export async function loadProjectRuns(client, projectId) {
46
- const params = new DrupalJsonApiParams();
47
- params.addFilter('ticket_id.project_id.id', projectId);
48
- params.addInclude(['ticket_id']);
49
- params.addSort('started_at', 'DESC');
50
- params.addPageLimit(50);
51
- const doc = (await client.get('gaia_run/gaia_run', params));
52
- const ticketsById = new Map();
53
- for (const inc of doc?.included ?? []) {
54
- if (inc.id)
55
- ticketsById.set(inc.id, inc);
56
- }
57
- return (doc?.data ?? []).map((item) => mapRunData(item, ticketsById));
155
+ export async function loadProjectRuns(client, projectId, criteria, page = 0) {
156
+ // The window this read asks for, stated once and handed BOTH to the query
157
+ // builder and to the page it produces, so the offset the screen positions with
158
+ // is the offset the server was sent (GAIA-312 D3).
159
+ const window = {
160
+ offset: page * LIST_PAGE_SIZE,
161
+ limit: LIST_PAGE_SIZE,
162
+ };
163
+ const params = buildRunQueryParams(projectId, criteria, {
164
+ size: window.limit,
165
+ index: page,
166
+ });
167
+ return loadPage(client, 'gaia_run/gaia_run', params, (data, included) => {
168
+ const ticketsById = new Map();
169
+ for (const inc of included)
170
+ if (inc.id)
171
+ ticketsById.set(inc.id, inc);
172
+ return data.map((item) => mapRunData(item, ticketsById));
173
+ }, page, window);
58
174
  }
59
175
  /**
60
176
  * Newest-STARTED first, and TOTAL (GAIA-255 D5).
@@ -0,0 +1,25 @@
1
+ import { BaseScreen, type ScreenContext } from '../../../Screen/screen.js';
2
+ import type { TicketDetailDocument } from '../../../types.js';
3
+ export interface RunDetailInput {
4
+ readonly document: TicketDetailDocument;
5
+ }
6
+ export declare const runDetailInputContext: import("../../../Routing/context.js").ContextKey<RunDetailInput>;
7
+ export declare class RunDetailScreen extends BaseScreen {
8
+ readonly frameId = "run";
9
+ topTab(): null;
10
+ static readonly route: {
11
+ id: string;
12
+ path: string;
13
+ parent: string;
14
+ fallback: {
15
+ routeId: string;
16
+ };
17
+ context: (_params: Readonly<Record<string, string>>, state: unknown, base: import("../../../Routing/context.js").ContextBag) => import("../../../Routing/context.js").ContextBag;
18
+ };
19
+ readonly id: string;
20
+ private document;
21
+ enter(context: ScreenContext): void;
22
+ render(context: ScreenContext): string[];
23
+ present(context: ScreenContext): import("../../../Screen/screen.js").ScreenPresentation;
24
+ reset(): void;
25
+ }
@@ -0,0 +1,51 @@
1
+ import { defineContext } from '../../../Routing/context.js';
2
+ import { BaseScreen, presentation, } from '../../../Screen/screen.js';
3
+ import { runDetailLines, runDetailTitle, } from '../Component/run-detail/index.js';
4
+ export const runDetailInputContext = defineContext('run-detail-input', 'input');
5
+ export class RunDetailScreen extends BaseScreen {
6
+ frameId = 'run';
7
+ topTab() {
8
+ return null;
9
+ }
10
+ static route = {
11
+ id: 'run.detail',
12
+ path: '/tickets/:ticketId/runs/:runId',
13
+ parent: 'ticket.detail',
14
+ fallback: { routeId: 'project.dashboard' },
15
+ context: (_params, state, base) => state
16
+ ? base.set(runDetailInputContext, {
17
+ document: state,
18
+ })
19
+ : base,
20
+ };
21
+ id = RunDetailScreen.route.id;
22
+ document = {};
23
+ enter(context) {
24
+ if (context.contexts?.has(runDetailInputContext))
25
+ this.document = context.contexts.get(runDetailInputContext).document;
26
+ context.requestDraw();
27
+ }
28
+ render(context) {
29
+ const runId = context.params?.runId;
30
+ if (!runId)
31
+ return ['Run detail requires runId'];
32
+ return [
33
+ runDetailTitle(this.document, runId),
34
+ '',
35
+ ...runDetailLines(this.document, runId).lines,
36
+ ];
37
+ }
38
+ present(context) {
39
+ const runId = context.params?.runId ?? '';
40
+ const rendered = runDetailLines(this.document, runId);
41
+ return presentation(rendered.lines, {
42
+ boldRows: rendered.boldRows,
43
+ title: runDetailTitle(this.document, runId),
44
+ meta: '↑↓ scroll · Esc back · q quit',
45
+ status: ' ↑↓ scroll · Esc back · q quit ',
46
+ });
47
+ }
48
+ reset() {
49
+ this.document = {};
50
+ }
51
+ }
@@ -0,0 +1,82 @@
1
+ import { ListScreen } from '../../../Screen/list-screen.js';
2
+ import { type ScreenContext, type ScreenPresentation } from '../../../Screen/screen.js';
3
+ import { type RunCriteria } from '../Data/query.js';
4
+ import { type RunPage, type RunRow } from '../Data/runs.js';
5
+ export declare class RunsScreen extends ListScreen<RunRow, number> {
6
+ readonly frameId = "project-runs";
7
+ readonly topTabOverlay = true;
8
+ protected readonly columns: readonly [{
9
+ readonly key: "run";
10
+ readonly label: "RUN";
11
+ readonly width: 8;
12
+ readonly fields: readonly ["drupal_internal__id"];
13
+ readonly get: (r: RunRow) => string;
14
+ }, {
15
+ readonly key: "ticket";
16
+ readonly label: "TICKET";
17
+ readonly width: 10;
18
+ readonly fields: readonly ["ticket_id"];
19
+ readonly get: (r: RunRow) => string;
20
+ }, {
21
+ readonly key: "state";
22
+ readonly label: "STATE";
23
+ readonly width: 8;
24
+ readonly fields: readonly ["state", "heartbeat", "claim_expires_at"];
25
+ readonly get: (r: RunRow, nowSeconds: number) => string;
26
+ }, {
27
+ readonly key: "phase";
28
+ readonly label: "PHASE";
29
+ readonly width: 10;
30
+ readonly fields: readonly ["state_at_start"];
31
+ readonly get: (r: RunRow) => string;
32
+ }, {
33
+ readonly key: "agent";
34
+ readonly label: "AGENT";
35
+ readonly width: 8;
36
+ readonly fields: readonly ["agent"];
37
+ readonly get: (r: RunRow) => string;
38
+ }, {
39
+ readonly key: "started";
40
+ readonly label: "STARTED";
41
+ readonly width: 0;
42
+ readonly fields: readonly ["started_at", "created"];
43
+ readonly get: (r: RunRow, nowSeconds: number) => string;
44
+ }];
45
+ protected readonly emptyMessage = "No runs for this project.";
46
+ topTab(): {
47
+ readonly level: "project";
48
+ readonly tab: "runs";
49
+ };
50
+ static readonly route: {
51
+ id: string;
52
+ path: string;
53
+ parent: string;
54
+ tab: {
55
+ id: string;
56
+ label: string;
57
+ scope: string;
58
+ order: number;
59
+ };
60
+ };
61
+ readonly id: string;
62
+ private criteria;
63
+ saveState(): {
64
+ criteria: RunCriteria;
65
+ page: number;
66
+ selectedRunId: string | null;
67
+ };
68
+ restoreState(token: unknown): void;
69
+ /** STATE is the derived liveness status and STARTED the relative start age, so
70
+ * the columns need a clock the row does not carry. */
71
+ protected tableContext(context: ScreenContext): number;
72
+ protected loadRows(context: ScreenContext, page: number): Promise<RunPage>;
73
+ /** Server is authoritative; the double-check protects against param-ignoring
74
+ * clients/test doubles, as on the ticket list. */
75
+ protected filterRow(row: RunRow): boolean;
76
+ protected renderLoading(): string[];
77
+ present(context: ScreenContext): ScreenPresentation;
78
+ activate(index: number, context: ScreenContext): boolean;
79
+ select(index: number, context: ScreenContext): boolean;
80
+ handleInput(input: string, context: ScreenContext): boolean;
81
+ reset(): void;
82
+ }
@@ -0,0 +1,169 @@
1
+ import { ListScreen } from '../../../Screen/list-screen.js';
2
+ import { presentation, } from '../../../Screen/screen.js';
3
+ import { CLOSED_FILTER_VALUES, cycleValue } from '../../cycle.js';
4
+ import { DEFAULT_RUN_CRITERIA } from '../Data/query.js';
5
+ import { loadProjectRuns, RUN_COLUMNS, selectedRunTicketId, } from '../Data/runs.js';
6
+ export class RunsScreen extends ListScreen {
7
+ frameId = 'project-runs';
8
+ topTabOverlay = true;
9
+ columns = RUN_COLUMNS;
10
+ emptyMessage = 'No runs for this project.';
11
+ topTab() {
12
+ return { level: 'project', tab: 'runs' };
13
+ }
14
+ static route = {
15
+ id: 'project.runs',
16
+ path: '/projects/:projectId/runs',
17
+ parent: 'project.dashboard',
18
+ tab: { id: 'runs', label: 'Runs', scope: 'project', order: 40 },
19
+ };
20
+ id = RunsScreen.route.id;
21
+ criteria = { ...DEFAULT_RUN_CRITERIA };
22
+ saveState() {
23
+ return {
24
+ criteria: { ...this.criteria },
25
+ page: this.page,
26
+ selectedRunId: this.rows[this.selectedIndex]?.id ?? null,
27
+ };
28
+ }
29
+ restoreState(token) {
30
+ if (!token || typeof token !== 'object')
31
+ return;
32
+ const saved = token;
33
+ if (saved.criteria) {
34
+ this.criteria = { ...saved.criteria };
35
+ // Tokens saved before GAIA-282 carry no closed; normalize so a stale
36
+ // token cannot smuggle undefined into a predicate testing !== null.
37
+ this.criteria.closed =
38
+ this.criteria.closed ?? DEFAULT_RUN_CRITERIA.closed;
39
+ }
40
+ // Clamped for the same reason `closed` is normalised above: a stale token
41
+ // must not smuggle a negative offset into the query.
42
+ this.restorePage(saved);
43
+ if (typeof saved.selectedRunId !== 'string')
44
+ return;
45
+ const index = this.rows.findIndex((row) => row.id === saved.selectedRunId);
46
+ if (index >= 0)
47
+ this.selectedIndex = index;
48
+ }
49
+ /** STATE is the derived liveness status and STARTED the relative start age, so
50
+ * the columns need a clock the row does not carry. */
51
+ tableContext(context) {
52
+ return context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
53
+ }
54
+ loadRows(context, page) {
55
+ if (!context.services || !context.project)
56
+ throw new Error('Runs requires project context');
57
+ return loadProjectRuns(context.services.client, context.project.id, this.criteria, page);
58
+ }
59
+ /** Server is authoritative; the double-check protects against param-ignoring
60
+ * clients/test doubles, as on the ticket list. */
61
+ filterRow(row) {
62
+ return this.criteria.closed === null || row.closed === this.criteria.closed;
63
+ }
64
+ renderLoading() {
65
+ return ['Loading runs…'];
66
+ }
67
+ present(context) {
68
+ const rows = this.rows;
69
+ // Standing segment: stated whenever rows are hidden, including the very
70
+ // first render (AC-4) — unconditional, as on the ticket list.
71
+ const closedSegment = this.criteria.closed === null
72
+ ? ''
73
+ : this.criteria.closed
74
+ ? 'closed: only'
75
+ : 'closed: hidden';
76
+ // The PAGE number, and only that; the collection's size is a different
77
+ // quantity, stated in its own segment by `countLabel` off the `meta.count`
78
+ // the page response carries (D6e). A page that offers a next page while
79
+ // showing few rows is correct rather than a bug: `links.next` is the
80
+ // end-of-collection signal, and a page can be full without being the last.
81
+ // The whole segment describes the LOADED page, not the requested index —
82
+ // that is what the `page` the value carries is for (D4); same reasoning,
83
+ // same shape and the same bottom-line home as the ticket list, which must
84
+ // not drift from this one. It is the same `loadedPage` the ARROW keys act
85
+ // on, so the number the operator reads and the number the keys move from
86
+ // are one value (R1).
87
+ //
88
+ // Built by `ListScreen` since GAIA-294, which is what makes "must not drift
89
+ // from the ticket list" a mechanism rather than a promise.
90
+ const paging = this.pagingSegments();
91
+ // The rows ON SCREEN and the size of the FILTERED COLLECTION, side by side:
92
+ // `7 of 1340 run(s)` when the server reported the total, `7 of ? run(s)`
93
+ // when it reported none (GAIA-292 D6c/D6d). Neither quantity replaces the
94
+ // other, so the row count never jumps; the total is read off the value the
95
+ // rows came with (D4/D4a) and from no second screen member — which is now
96
+ // structural rather than careful, because the server answered both from one
97
+ // query. Empty while the page loads (D6c), which is why the segments are
98
+ // joined rather than concatenated — an omitted count must not leave the line
99
+ // opening with a separator.
100
+ const count = this.countLabel('run');
101
+ return presentation(this.render(context), {
102
+ selectable: rows.length,
103
+ selectedIndex: this.selectedIndex,
104
+ selectedRow: rows.length ? 1 + this.selectedIndex : -1,
105
+ title: `${context.project?.name ?? context.project?.slug ?? ''} · runs`,
106
+ meta: [
107
+ count,
108
+ closedSegment,
109
+ 'Enter ticket · Tab tabs · x closed · Esc back',
110
+ ]
111
+ .filter(Boolean)
112
+ .join(' · '),
113
+ status: ` ${paging.join(' · ')} · ↑↓ select · Enter ticket · Tab tabs · x closed · Esc back · q quit `,
114
+ });
115
+ }
116
+ activate(index, context) {
117
+ const id = this.readyValue
118
+ ? selectedRunTicketId(this.readyValue.rows, index)
119
+ : null;
120
+ if (!id || !context.navigate)
121
+ return false;
122
+ context.navigate('ticket.detail', { ticketId: id, tab: 'runs' });
123
+ return true;
124
+ }
125
+ select(index, context) {
126
+ if (index < 0 || index >= this.rows.length)
127
+ return false;
128
+ this.selectedIndex = index;
129
+ context.requestDraw();
130
+ return true;
131
+ }
132
+ handleInput(input, context) {
133
+ if (super.handleInput(input, context))
134
+ return true;
135
+ if (input === 'x') {
136
+ this.criteria = {
137
+ ...this.criteria,
138
+ closed: cycleValue(CLOSED_FILTER_VALUES, this.criteria.closed),
139
+ };
140
+ // A criteria change starts over at page one, or narrowing on page 4 lands
141
+ // on an empty page with no rows and no explanation (D5).
142
+ this.page = 0;
143
+ this.selectedIndex = 0;
144
+ this.retry(context);
145
+ return true;
146
+ }
147
+ // The ARROW keys since the operator's live review (D6 chose `]` / `[`); the
148
+ // kernel handles neither sequence itself, so the screen claims them. This
149
+ // screen has no search field, so there is nothing they could shadow.
150
+ // Both move from the page ON SCREEN, never from the requested index (R1).
151
+ if (this.handlePagingInput(input, context))
152
+ return true;
153
+ const rows = this.readyValue?.rows;
154
+ if (!rows)
155
+ return false;
156
+ if (this.handleCursorInput(input, context))
157
+ return true;
158
+ if (input === '\r' || input === '\n')
159
+ return this.activate(this.selectedIndex, context);
160
+ return false;
161
+ }
162
+ reset() {
163
+ // The page, the cursor and the counter — a reset means a new project or
164
+ // session, hence a different collection (GAIA-294).
165
+ this.resetList();
166
+ this.criteria = { ...DEFAULT_RUN_CRITERIA };
167
+ super.reset();
168
+ }
169
+ }
@@ -0,0 +1,42 @@
1
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
2
+ import { type ScreenAction, type ScreenContext, type ScreenPresentation } from '../../../Screen/screen.js';
3
+ import { type StatisticsResult } from '../Data/statistics.js';
4
+ export declare class DashboardScreen extends AsyncScreen<StatisticsResult> {
5
+ readonly frameId = "dashboard";
6
+ topTab(context: ScreenContext): {
7
+ readonly level: "project";
8
+ readonly tab: "dashboard";
9
+ } | {
10
+ readonly level: "root";
11
+ readonly tab: "dashboard";
12
+ };
13
+ reload(context: ScreenContext): void;
14
+ static readonly route: {
15
+ id: string;
16
+ path: string;
17
+ fallback: {
18
+ routeId: string;
19
+ };
20
+ tab: {
21
+ id: string;
22
+ label: string;
23
+ scope: string;
24
+ scopes: string[];
25
+ order: number;
26
+ };
27
+ };
28
+ readonly id: string;
29
+ private status;
30
+ actionForInput(input: string, context: ScreenContext): ScreenAction | undefined;
31
+ protected load(context: ScreenContext): Promise<StatisticsResult>;
32
+ protected renderLoading(): string[];
33
+ protected renderReady(result: StatisticsResult, context: ScreenContext): string[];
34
+ renderNonInteractive(context: ScreenContext): string[];
35
+ nonInteractive(context: ScreenContext): {
36
+ stream?: "stderr";
37
+ exitCode?: number;
38
+ lines: string[];
39
+ };
40
+ present(context: ScreenContext): ScreenPresentation;
41
+ reset(): void;
42
+ }