@gaia-ai/addon-gaia-ui 0.15.0 → 0.15.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.
Files changed (56) hide show
  1. package/dist/src/Component/list-chrome/index.d.ts +6 -3
  2. package/dist/src/Component/list-chrome/index.js +10 -1
  3. package/dist/src/Entity/conductor/Data/conductors.d.ts +16 -8
  4. package/dist/src/Entity/conductor/Data/conductors.js +15 -8
  5. package/dist/src/Entity/conductor/Screen/conductors.d.ts +2 -2
  6. package/dist/src/Entity/conductor/Screen/project-conductors.d.ts +2 -2
  7. package/dist/src/Entity/project/Data/with-tickets.js +1 -0
  8. package/dist/src/Entity/project/Data/workload.js +3 -10
  9. package/dist/src/Entity/run/Component/run-detail/index.d.ts +2 -2
  10. package/dist/src/Entity/run/Component/run-detail/index.js +37 -35
  11. package/dist/src/Entity/run/Data/query.js +2 -0
  12. package/dist/src/Entity/run/Screen/run-detail.js +2 -2
  13. package/dist/src/Entity/team/Data/projects.js +1 -0
  14. package/dist/src/Entity/team/Data/teams.js +1 -0
  15. package/dist/src/Entity/ticket/Component/ticket-content/model.js +0 -3
  16. package/dist/src/Entity/ticket/Data/parents-all.d.ts +14 -0
  17. package/dist/src/Entity/ticket/Data/parents-all.js +44 -0
  18. package/dist/src/Entity/ticket/Data/parents.js +1 -0
  19. package/dist/src/Entity/ticket/Data/projects-search.d.ts +35 -0
  20. package/dist/src/Entity/ticket/Data/projects-search.js +61 -0
  21. package/dist/src/Entity/ticket/Data/query.d.ts +12 -0
  22. package/dist/src/Entity/ticket/Data/query.js +14 -2
  23. package/dist/src/Entity/ticket/Data/tickets.d.ts +12 -1
  24. package/dist/src/Entity/ticket/Data/tickets.js +25 -1
  25. package/dist/src/Entity/ticket/Form/create-data.js +3 -2
  26. package/dist/src/Entity/ticket/Form/edit-data.js +1 -1
  27. package/dist/src/Entity/ticket/Form/my-work-parent-source.d.ts +11 -0
  28. package/dist/src/Entity/ticket/Form/my-work-parent-source.js +41 -0
  29. package/dist/src/Entity/ticket/Form/project-filter.d.ts +36 -0
  30. package/dist/src/Entity/ticket/Form/project-filter.js +36 -0
  31. package/dist/src/Entity/ticket/Form/project-source.d.ts +20 -0
  32. package/dist/src/Entity/ticket/Form/project-source.js +40 -0
  33. package/dist/src/Entity/ticket/Screen/my-work-parent-filter-form.d.ts +30 -0
  34. package/dist/src/Entity/ticket/Screen/my-work-parent-filter-form.js +161 -0
  35. package/dist/src/Entity/ticket/Screen/my-work.d.ts +21 -2
  36. package/dist/src/Entity/ticket/Screen/my-work.js +63 -4
  37. package/dist/src/Entity/ticket/Screen/project-filter-form.d.ts +30 -0
  38. package/dist/src/Entity/ticket/Screen/project-filter-form.js +173 -0
  39. package/dist/src/Entity/ticket/Screen/ticket-filter-bar.d.ts +29 -0
  40. package/dist/src/Entity/ticket/Screen/ticket-filter-bar.js +129 -0
  41. package/dist/src/Entity/ticket/Screen/tickets.d.ts +10 -5
  42. package/dist/src/Entity/ticket/Screen/tickets.js +35 -18
  43. package/dist/src/Entity/user/Data/member-context.js +1 -0
  44. package/dist/src/Entity/user/Data/users.js +4 -1
  45. package/dist/src/Entity/workspace/Data/members.js +1 -0
  46. package/dist/src/Routing/routes.js +4 -0
  47. package/dist/src/Statistics/comparison-chart.d.ts +5 -0
  48. package/dist/src/Statistics/comparison-chart.js +517 -0
  49. package/dist/src/Statistics/figures.js +2 -20
  50. package/dist/src/Statistics/member-stats.js +1 -0
  51. package/dist/src/lib/repo-url.js +1 -1
  52. package/dist/src/lib/theme.d.ts +18 -0
  53. package/dist/src/lib/theme.js +18 -0
  54. package/dist/src/route.js +21 -24
  55. package/dist/src/types.d.ts +1 -1
  56. package/package.json +2 -2
@@ -3,11 +3,14 @@ import { type VNode } from '@opentui/core';
3
3
  export interface ListFilterBadge {
4
4
  /** Stable id suffix — node id is `${stripId}-${id}`. */
5
5
  id: string;
6
- /** Idle label (key hint), e.g. `/ search` or `x closed`. */
6
+ /**
7
+ * Shortcut plus a space plus the idle caption, e.g. `/ search` or `x closed`.
8
+ * The first space-delimited token is the shortcut rendered before an active value.
9
+ */
7
10
  label: string;
8
11
  /**
9
- * When set, the badge is active: {@link FOREGROUND_ACTIVE} fg and this value text
10
- * (e.g. `closed: hidden`, `who: cw`).
12
+ * Active caption rendered after the shortcut (e.g. `closed: hidden`, `who: cw`).
13
+ * Undefined or empty keeps the badge idle: {@link label} only, idle palette.
11
14
  */
12
15
  value?: string;
13
16
  }
@@ -32,7 +32,16 @@ export const filterBadgeStrip = (id, badges) => {
32
32
  paddingLeft: 2,
33
33
  paddingRight: 2,
34
34
  flexDirection: 'row',
35
- }, textNode({
35
+ }, ...(active
36
+ ? [
37
+ textNode({
38
+ id: `${id}-${badge.id}-key`,
39
+ content: `${badge.label.split(' ', 1)[0]} `,
40
+ bg: DEFAULT_BACKGROUND,
41
+ fg: FOREGROUND,
42
+ }),
43
+ ]
44
+ : []), textNode({
36
45
  id: `${id}-${badge.id}`,
37
46
  content: label,
38
47
  bg: active ? BACKGROUND_ACTIVE : DEFAULT_BACKGROUND,
@@ -11,6 +11,8 @@ export interface Conductor {
11
11
  machineId: string | undefined;
12
12
  /** Liveness, as the control plane reports it. */
13
13
  status: string | undefined;
14
+ /** Operator-set lifecycle flag; inactive conductors are never assignable. */
15
+ active: boolean;
14
16
  /** The conductor's project, `null` when unlinked. */
15
17
  projectId: string | null;
16
18
  /** The user who owns this record, `null` when unowned (GAIA-460 D8/D9). */
@@ -188,13 +190,16 @@ export declare const CONDUCTOR_TABLE: {
188
190
  }, {
189
191
  /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
190
192
  * `online | stale | offline` — this screen can never produce
191
- * `registry-only`, because every row IS a control-plane record — whose
192
- * longest is 7, so 9 shows each of them whole. */
193
+ * `registry-only`, because every row IS a control-plane record — and an
194
+ * inactive row appends a trailing `!`, whose longest, `offline!`, is 8,
195
+ * so 9 still shows each of them whole (GAIA-542 N1: `(inactive)` does
196
+ * not fit this budget without disturbing every other column's exact
197
+ * width, so the marker is a glyph rather than a word). */
193
198
  readonly key: "status";
194
199
  readonly label: "STATUS";
195
200
  readonly width: 9;
196
- readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
197
- readonly get: (row: ConductorRow) => ConductorStatus;
201
+ readonly fields: readonly ["status", "active", "lease_expires_at", "last_seen"];
202
+ readonly get: (row: ConductorRow) => string;
198
203
  }, {
199
204
  /** `humanizeAge`'s own vocabulary, exactly as on the projects list: `—`,
200
205
  * `just now`, `<n>m|h|d ago`. 8 is the longest of them. */
@@ -296,13 +301,16 @@ export declare const PROJECT_CONDUCTOR_TABLE: {
296
301
  } | {
297
302
  /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
298
303
  * `online | stale | offline` — this screen can never produce
299
- * `registry-only`, because every row IS a control-plane record — whose
300
- * longest is 7, so 9 shows each of them whole. */
304
+ * `registry-only`, because every row IS a control-plane record — and an
305
+ * inactive row appends a trailing `!`, whose longest, `offline!`, is 8,
306
+ * so 9 still shows each of them whole (GAIA-542 N1: `(inactive)` does
307
+ * not fit this budget without disturbing every other column's exact
308
+ * width, so the marker is a glyph rather than a word). */
301
309
  readonly key: "status";
302
310
  readonly label: "STATUS";
303
311
  readonly width: 9;
304
- readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
305
- readonly get: (row: ConductorRow) => ConductorStatus;
312
+ readonly fields: readonly ["status", "active", "lease_expires_at", "last_seen"];
313
+ readonly get: (row: ConductorRow) => string;
306
314
  } | {
307
315
  /** `humanizeAge`'s own vocabulary, exactly as on the projects list: `—`,
308
316
  * `just now`, `<n>m|h|d ago`. 8 is the longest of them. */
@@ -20,6 +20,7 @@ const toConductor = (r) => ({
20
20
  // Registration key (GAIA-353 renamed machine_id → conductor_id).
21
21
  machineId: str(r.attr('conductor_id')) ?? str(r.attr('machine_id')),
22
22
  status: str(r.attr('status')),
23
+ active: r.attr('active') !== false,
23
24
  projectId: r.rel('project_id'),
24
25
  ownerUserId: r.rel('owner_user_id'),
25
26
  });
@@ -199,13 +200,16 @@ export const CONDUCTOR_TABLE = {
199
200
  {
200
201
  /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
201
202
  * `online | stale | offline` — this screen can never produce
202
- * `registry-only`, because every row IS a control-plane record — whose
203
- * longest is 7, so 9 shows each of them whole. */
203
+ * `registry-only`, because every row IS a control-plane record — and an
204
+ * inactive row appends a trailing `!`, whose longest, `offline!`, is 8,
205
+ * so 9 still shows each of them whole (GAIA-542 N1: `(inactive)` does
206
+ * not fit this budget without disturbing every other column's exact
207
+ * width, so the marker is a glyph rather than a word). */
204
208
  key: 'status',
205
209
  label: 'STATUS',
206
210
  width: 9,
207
- fields: ['status', 'lease_expires_at', 'last_seen'],
208
- get: (row) => row.derivedStatus,
211
+ fields: ['status', 'active', 'lease_expires_at', 'last_seen'],
212
+ get: (row) => row.active ? row.derivedStatus : `${row.derivedStatus}!`,
209
213
  },
210
214
  {
211
215
  /** `humanizeAge`'s own vocabulary, exactly as on the projects list: `—`,
@@ -382,14 +386,17 @@ const toConductorRow = (raw, ctx, machinesById) => {
382
386
  /**
383
387
  * The query both reads share: the narrowing, the declared order, the sideload.
384
388
  *
385
- * `sort=-last_seen` is not cosmetic (GAIA-460 D7, GAIA-458 AC-2). Paging over a
386
- * collection with no declared order lets a row repeat on two pages while another
387
- * is skipped entirely, so the order is a correctness property of the paging
388
- * before it is a convenience for the reader.
389
+ * `sort=-last_seen,-drupal_internal__id` is not cosmetic (GAIA-460 D7,
390
+ * GAIA-458 AC-2). Paging over a collection with no total order lets a row
391
+ * repeat on two pages while another is skipped entirely, so the order is a
392
+ * correctness property of the paging before it is a convenience for the reader.
393
+ * `last_seen` is nullable and two conductors can share a heartbeat; the serial
394
+ * id is the unique second key that makes offset paging well defined.
389
395
  */
390
396
  const conductorParams = (query) => {
391
397
  const params = new DrupalJsonApiParams().addInclude(['machine']);
392
398
  params.addSort('last_seen', 'DESC');
399
+ params.addSort('drupal_internal__id', 'DESC');
393
400
  if (query.projectId)
394
401
  params.addFilter('project_id.id', query.projectId);
395
402
  if (query.ownerUserId)
@@ -53,8 +53,8 @@ export declare class ConductorsScreen extends ListScreen<ConductorRow, Conductor
53
53
  readonly key: "status";
54
54
  readonly label: "STATUS";
55
55
  readonly width: 9;
56
- readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
57
- readonly get: (row: ConductorRow) => import("../Data/liveness.js").ConductorStatus;
56
+ readonly fields: readonly ["status", "active", "lease_expires_at", "last_seen"];
57
+ readonly get: (row: ConductorRow) => string;
58
58
  }, {
59
59
  readonly key: "age";
60
60
  readonly label: "AGE";
@@ -46,8 +46,8 @@ export declare class ProjectConductorsScreen extends ListScreen<ConductorRow, Co
46
46
  readonly key: "status";
47
47
  readonly label: "STATUS";
48
48
  readonly width: 9;
49
- readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
50
- readonly get: (row: ConductorRow) => import("../Data/liveness.js").ConductorStatus;
49
+ readonly fields: readonly ["status", "active", "lease_expires_at", "last_seen"];
50
+ readonly get: (row: ConductorRow) => string;
51
51
  } | {
52
52
  readonly key: "age";
53
53
  readonly label: "AGE";
@@ -31,6 +31,7 @@ export async function loadProjectWithTickets(client, projectSlug) {
31
31
  parent: null,
32
32
  who: null,
33
33
  owner: null,
34
+ project: null,
34
35
  });
35
36
  return {
36
37
  project,
@@ -10,16 +10,9 @@ const relationshipIds = (relationship) => {
10
10
  return (Array.isArray(data) ? data : [data]).map(({ id }) => id);
11
11
  };
12
12
  const repositoryUrl = (value) => {
13
- if (!Array.isArray(value))
13
+ if (!value || typeof value !== 'object')
14
14
  return undefined;
15
- for (const repository of value) {
16
- if (!repository || typeof repository !== 'object')
17
- continue;
18
- const url = asString(repository.url);
19
- if (url)
20
- return url;
21
- }
22
- return undefined;
15
+ return asString(value.url);
23
16
  };
24
17
  async function loadProjectContext(client, projectId) {
25
18
  const params = new DrupalJsonApiParams()
@@ -37,7 +30,7 @@ async function loadProjectContext(client, projectId) {
37
30
  return name ? [name] : [];
38
31
  });
39
32
  const key = asString(project.attributes?.key);
40
- const repository = repositoryUrl(project.attributes?.repos);
33
+ const repository = repositoryUrl(project.attributes?.repository);
41
34
  return {
42
35
  id: project.id ?? projectId,
43
36
  name: asString(project.attributes?.name) ?? projectId,
@@ -14,7 +14,7 @@ export declare function runDetailTitle(document: TicketDetailDocument, runId: st
14
14
  * `presentation()` puts the kernel's body-window root over it, because the id of
15
15
  * that slot is the kernel's to know and a component may not import it.
16
16
  */
17
- export declare function runDetailTree(document: TicketDetailDocument, runId: string | null | undefined): VNode;
17
+ export declare function runDetailTree(document: TicketDetailDocument, runId: string | null | undefined, baseUrl?: string | undefined): VNode;
18
18
  /**
19
19
  * The run block as PLAIN TEXT — the pipe's own output (spec F5).
20
20
  *
@@ -30,4 +30,4 @@ export declare function runDetailTree(document: TicketDetailDocument, runId: str
30
30
  * Nothing here measures a string (R5): a pipe's text is unpadded by definition,
31
31
  * so this is the same rows the tree carries and no column arithmetic at all.
32
32
  */
33
- export declare function runDetailRows(document: TicketDetailDocument, runId: string | null | undefined): string[];
33
+ export declare function runDetailRows(document: TicketDetailDocument, runId: string | null | undefined, baseUrl?: string | undefined): string[];
@@ -37,19 +37,6 @@ function relId(resource, name) {
37
37
  return null;
38
38
  return Array.isArray(data) ? (data[0]?.id ?? null) : data.id;
39
39
  }
40
- /**
41
- * A `text_long` value as a plain string. `log`/`error_log` arrive as
42
- * `{value, format, processed}` when captured and as `null` when they never were
43
- * (measured null on GAIA-255's own runs) — the same shape the ticket description
44
- * has, unwrapped by the same rule.
45
- */
46
- function longText(value) {
47
- if (value && typeof value === 'object' && 'value' in value) {
48
- const inner = value.value;
49
- return typeof inner === 'string' ? inner : '';
50
- }
51
- return typeof value === 'string' ? value : '';
52
- }
53
40
  /** A scalar field as a display row value: the value, or an explicit dash. */
54
41
  function scalar(value) {
55
42
  if (typeof value === 'string' && value !== '')
@@ -103,7 +90,7 @@ export function runDetailTitle(document, runId) {
103
90
  * it once the screen handed over nodes, and a second description of a screen
104
91
  * that nobody draws is exactly what drifts.
105
92
  */
106
- function keyedSections(document, runId) {
93
+ function keyedSections(document, runId, baseUrl) {
107
94
  const run = runResource(document, runId);
108
95
  if (!run) {
109
96
  return [
@@ -115,20 +102,23 @@ function keyedSections(document, runId) {
115
102
  ];
116
103
  }
117
104
  const a = run.attributes ?? {};
118
- const textSection = (key, heading, value, emptyMessage) => {
119
- const text = longText(value);
105
+ // The transcript/error payload no longer lives on the entity (GAIA-556) —
106
+ // preSave() writes it to a private file and clears the field, so what the
107
+ // API returns is the byte count, not the text. `bytes > 0` and "unset"
108
+ // read differently: the first is a payload retrievable through the run
109
+ // log download route, the second is either a true empty log or, for the
110
+ // transcript, a `capture_logs: false` conductor — the operator cannot
111
+ // tell those apart from a truncated body, so say "not captured" instead
112
+ // of implying failure.
113
+ const logSizeSection = (key, heading, bytes, notCapturedMessage, kind) => {
114
+ const captured = typeof bytes === 'number';
115
+ const text = captured
116
+ ? `Captured (${bytes} bytes). ${runLogUrl(baseUrl, run.id, kind) ?? 'Retrieve via the run log download route.'}`
117
+ : notCapturedMessage;
120
118
  return {
121
119
  key,
122
120
  heading,
123
- // Keyed WITHIN the section, so line n of a transcript is `<section>-n`
124
- // whatever the fields above it do. A transcript only ever grows at the
125
- // end, which is the one shape a positional key is honest about.
126
- rows: text
127
- ? text.split('\n').map((line, index) => ({
128
- key: `${key}-${index}`,
129
- text: line,
130
- }))
131
- : [{ key: `${key}-empty`, text: emptyMessage }],
121
+ rows: [{ key: `${key}-empty`, text }],
132
122
  };
133
123
  };
134
124
  return [
@@ -186,12 +176,24 @@ function keyedSections(document, runId) {
186
176
  },
187
177
  ],
188
178
  },
189
- // R5: no cap and no truncation — a truncated log would be a silent lie. It is
190
- // scrollable like any other body.
191
- textSection('log', 'Log', a.log, 'No log captured.'),
192
- textSection('error-log', 'Error log', a.error_log, 'No errors logged.'),
179
+ // R5 no longer applies to what's rendered here: the payload moved to a
180
+ // private file (GAIA-556), so the section shows the size, not the text.
181
+ logSizeSection('log', 'Log', a.log_bytes, 'Transcript not captured.', 'log'),
182
+ logSizeSection('error-log', 'Error log', a.error_log_bytes, 'No errors logged.', 'error_log'),
193
183
  ];
194
184
  }
185
+ /**
186
+ * The run-log download route's resolved URL (`_gaia-api/run/{uuid}/log/{kind}`,
187
+ * @see gaia-cli/addons/remote-drupal/src/index.ts createRunLogFetcher), so an
188
+ * operator reading the run detail screen has something to open rather than a
189
+ * sentence naming a route with no address. `undefined` when the control plane's
190
+ * base URL is not known, which the caller falls back on.
191
+ */
192
+ function runLogUrl(baseUrl, runId, kind) {
193
+ if (!baseUrl || !runId)
194
+ return undefined;
195
+ return `${baseUrl.replace(/\/+$/, '')}/_gaia-api/run/${runId}/log/${kind}`;
196
+ }
195
197
  /**
196
198
  * One body row as a renderable. Bold is a PROP here, never an index (D3), and
197
199
  * it is stated on every row rather than only on the bold ones — a log line that
@@ -211,8 +213,8 @@ const textRow = (key, text, bold = false) => textNode({ id: `run-detail-${key}`,
211
213
  * asserts the two projections row for row, so a section reachable from only one
212
214
  * of them fails rather than diverges quietly.
213
215
  */
214
- function flatRows(document, runId) {
215
- return keyedSections(document, runId).flatMap((section) => [
216
+ function flatRows(document, runId, baseUrl) {
217
+ return keyedSections(document, runId, baseUrl).flatMap((section) => [
216
218
  { key: `section-${section.key}`, text: section.heading, bold: true },
217
219
  ...section.rows.map((entry) => ({
218
220
  key: entry.key,
@@ -238,8 +240,8 @@ function flatRows(document, runId) {
238
240
  * `presentation()` puts the kernel's body-window root over it, because the id of
239
241
  * that slot is the kernel's to know and a component may not import it.
240
242
  */
241
- export function runDetailTree(document, runId) {
242
- return columnNode('run-detail', ...flatRows(document, runId).map((entry) => textRow(entry.key, entry.text, entry.bold)));
243
+ export function runDetailTree(document, runId, baseUrl) {
244
+ return columnNode('run-detail', ...flatRows(document, runId, baseUrl).map((entry) => textRow(entry.key, entry.text, entry.bold)));
243
245
  }
244
246
  /**
245
247
  * The run block as PLAIN TEXT — the pipe's own output (spec F5).
@@ -256,6 +258,6 @@ export function runDetailTree(document, runId) {
256
258
  * Nothing here measures a string (R5): a pipe's text is unpadded by definition,
257
259
  * so this is the same rows the tree carries and no column arithmetic at all.
258
260
  */
259
- export function runDetailRows(document, runId) {
260
- return flatRows(document, runId).map((entry) => entry.text);
261
+ export function runDetailRows(document, runId, baseUrl) {
262
+ return flatRows(document, runId, baseUrl).map((entry) => entry.text);
261
263
  }
@@ -65,6 +65,7 @@ export function buildConductorRunQueryParams(conductorId, page = { size: LIST_PA
65
65
  params.addFields('gaia_ticket--gaia_ticket', CONDUCTOR_RUN_TICKET_INCLUDE_FIELDS);
66
66
  params.addFields('gaia_run--gaia_run', RUN_LIST_FIELDS);
67
67
  params.addSort('started_at', 'DESC');
68
+ params.addSort('drupal_internal__id', 'DESC');
68
69
  params.addPageLimit(page.size);
69
70
  if (page.index > 0)
70
71
  params.addPageOffset(page.index * page.size);
@@ -79,6 +80,7 @@ function applyRunCriteria(params, criteria, page) {
79
80
  params.addFields('gaia_ticket--gaia_ticket', RUN_TICKET_INCLUDE_FIELDS);
80
81
  params.addFields('gaia_run--gaia_run', RUN_LIST_FIELDS);
81
82
  params.addSort('started_at', 'DESC');
83
+ params.addSort('drupal_internal__id', 'DESC');
82
84
  params.addPageLimit(page.size);
83
85
  // Page one carries no offset, so its query is the shortest it can be.
84
86
  if (page.index > 0)
@@ -42,7 +42,7 @@ export class RunDetailScreen extends AsyncScreen {
42
42
  return ['Loading run…'];
43
43
  }
44
44
  renderReady(document, context) {
45
- return runDetailTree(document, context.params?.runId ?? '');
45
+ return runDetailTree(document, context.params?.runId ?? '', context.services?.baseUrl);
46
46
  }
47
47
  chrome(context) {
48
48
  return {
@@ -107,6 +107,6 @@ export class RunDetailScreen extends AsyncScreen {
107
107
  * 97.7 % of a 1.34 MB captured agent transcript.
108
108
  */
109
109
  renderNonInteractive(context) {
110
- return runDetailRows(this.readyValue ?? {}, context.params?.runId ?? '');
110
+ return runDetailRows(this.readyValue ?? {}, context.params?.runId ?? '', context.services?.baseUrl);
111
111
  }
112
112
  }
@@ -7,6 +7,7 @@ export async function listLinkedProjectIds(client, teamId) {
7
7
  while (true) {
8
8
  const params = new DrupalJsonApiParams()
9
9
  .addFilter('teams.id', teamId)
10
+ .addSort('drupal_internal__id', 'ASC')
10
11
  .addPageLimit(LIST_PAGE_SIZE);
11
12
  if (offset > 0)
12
13
  params.addPageOffset(offset);
@@ -160,6 +160,7 @@ export const MEMBERSHIP_RESOURCE = 'og_membership/default';
160
160
  export async function readMembershipPage(client, filters, offset) {
161
161
  const params = new DrupalJsonApiParams()
162
162
  .addFilter('state', MEMBERSHIP_STATE_ACTIVE)
163
+ .addSort('drupal_internal__id', 'ASC')
163
164
  .addPageLimit(MEMBERSHIP_PAGE_SIZE);
164
165
  for (const [field, value] of Object.entries(filters)) {
165
166
  params.addFilter(field, value);
@@ -100,9 +100,6 @@ const lifecycleProperties = (document) => {
100
100
  const changed = asString(attributes.changed);
101
101
  if (changed !== undefined)
102
102
  extras.push(property('changed', changed));
103
- const gitlab = asString(attributes.gitlab_assignee_username);
104
- if (gitlab !== undefined)
105
- extras.push(property('gitlab assignee', gitlab));
106
103
  return extras;
107
104
  };
108
105
  const technicalProperties = (byHeading, document, labels) => {
@@ -0,0 +1,14 @@
1
+ import type { DropshClient } from '../../../types.js';
2
+ import { type ListOutcome } from '../../transport.js';
3
+ import type { ParentCandidate } from './parents.js';
4
+ /** A page of candidates for the My work parent picker. */
5
+ export declare const MY_WORK_PARENT_PAGE_SIZE = 50;
6
+ /**
7
+ * One page of tickets that have children, across every project.
8
+ *
9
+ * The `filter[identifier][operator]=CONTAINS` shape is the same one the
10
+ * project-scoped parent picker uses for `match === 'identifier'` (GAIA-284,
11
+ * PM steer). `has_children = 1` filters on the server side via
12
+ * `HasChildrenFilterSubscriber`.
13
+ */
14
+ export declare function loadAllParentCandidates(client: DropshClient, query: string): Promise<ListOutcome<ParentCandidate>>;
@@ -0,0 +1,44 @@
1
+ // GAIA-540 — the My work PARENT CANDIDATE read. Mirrors `loadParentCandidates`
2
+ // from `parents.ts` but does NOT scope the read by `filter[project_id.id]`:
3
+ // My work spans every project the operator is assigned to, so a parent picker
4
+ // they reach from My work must offer parents from all of them.
5
+ //
6
+ // The remaining policy bits stay: `parent: null` and `who: null` criteria
7
+ // (F7), so an active parent filter can never narrow the picker to that
8
+ // parent's own children, and `has_children = 1` so the picker offers only
9
+ // tickets that CAN be picked.
10
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
11
+ import { messageOf } from '../../transport.js';
12
+ /** A page of candidates for the My work parent picker. */
13
+ export const MY_WORK_PARENT_PAGE_SIZE = 50;
14
+ const toCandidate = (item) => {
15
+ const id = item.id ?? '';
16
+ return {
17
+ id,
18
+ identifier: item.attributes?.identifier ?? id,
19
+ title: item.attributes?.title ?? 'Untitled',
20
+ };
21
+ };
22
+ /**
23
+ * One page of tickets that have children, across every project.
24
+ *
25
+ * The `filter[identifier][operator]=CONTAINS` shape is the same one the
26
+ * project-scoped parent picker uses for `match === 'identifier'` (GAIA-284,
27
+ * PM steer). `has_children = 1` filters on the server side via
28
+ * `HasChildrenFilterSubscriber`.
29
+ */
30
+ export async function loadAllParentCandidates(client, query) {
31
+ const params = new DrupalJsonApiParams()
32
+ .addPageLimit(MY_WORK_PARENT_PAGE_SIZE)
33
+ .addFields('gaia_ticket--gaia_ticket', ['identifier', 'title']);
34
+ if (query)
35
+ params.addFilter('identifier', query, 'CONTAINS');
36
+ params.addFilter('has_children', '1');
37
+ try {
38
+ const doc = (await client.get('gaia_ticket/gaia_ticket', params));
39
+ return { rows: (doc?.data ?? []).map(toCandidate) };
40
+ }
41
+ catch (caught) {
42
+ return { rows: [], error: messageOf(caught) };
43
+ }
44
+ }
@@ -85,6 +85,7 @@ export async function loadParentCandidates(client, projectId, query, match = 'id
85
85
  who: null,
86
86
  // Same rule for the owner filter (GAIA-416).
87
87
  owner: null,
88
+ project: null,
88
89
  },
89
90
  // Its own page, stated explicitly: the picker discloses truncation at
90
91
  // PARENT_PAGE_SIZE, so inheriting the list's LIST_PAGE_SIZE (20 since
@@ -0,0 +1,35 @@
1
+ import type { DropshClient } from '../../../types.js';
2
+ import { type ListOutcome } from '../../transport.js';
3
+ /** One project offered as a possible filter, as the picker needs it. */
4
+ export interface ProjectCandidate {
5
+ id: string;
6
+ /** The project's `name`; falls back to the uuid, so nothing renders blank. */
7
+ name: string;
8
+ }
9
+ /**
10
+ * The most rows one read can return: Drupal's `OffsetPage::SIZE_MAX`, verified
11
+ * against the control plane.
12
+ *
13
+ * Exported because a bag of exactly this length may be a TRUNCATED answer, and
14
+ * only the module that performs the read knows the number. The picker does
15
+ * not currently disclose truncation here (project names are short and a small
16
+ * bag is rare), but the constant is the same shape `parents` and `users`
17
+ * already publish so a future disclosure can find it.
18
+ */
19
+ export declare const PROJECT_PAGE_SIZE = 50;
20
+ /**
21
+ * One page of projects whose `name` contains the typed fragment.
22
+ *
23
+ * `name` is a plain string field on `gaia_project` (no `display_name`
24
+ * computed field), so `filter[name][operator]=CONTAINS` is a real server-side
25
+ * filter — matching is name-only, the same restriction the Who picker's
26
+ * matching carries for the same reason (R4 of GAIA-299). The two-character
27
+ * gate (AC-3 of GAIA-540) is THIS field's policy, encoded in the source that
28
+ * calls this read, NOT here — the data layer returns whatever it was asked
29
+ * for, including an empty array for the empty query.
30
+ *
31
+ * Yields the OUTCOME rather than a swallowed array, so the adapter can record
32
+ * a `projects` error like every other option bag (D4) instead of presenting an
33
+ * empty candidate list as "no projects exist".
34
+ */
35
+ export declare function loadProjectCandidates(client: DropshClient, query: string): Promise<ListOutcome<ProjectCandidate>>;
@@ -0,0 +1,61 @@
1
+ // GAIA-540 — the PROJECT CANDIDATE read for the project-filter picker (the
2
+ // My work screen's `r` key on the bar). A data module like its neighbours:
3
+ // it returns projects and knows nothing about forms, `Option`s or popups; the
4
+ // label, the form declaration and the screen all sit above this file (boundary
5
+ // guard G2, the `Data/` leaf rule).
6
+ //
7
+ // The query is built directly against `gaia_project` (not through
8
+ // `buildTicketQueryParams`, since this read is NOT a ticket read): every
9
+ // gaia_project the operator can name is fair game, and the
10
+ // `filter[name][operator]=CONTAINS&filter[name][value]=<query>` form is
11
+ // exactly what Drupal JSON:API expects. `display_name`-style computed fields
12
+ // would not be filterable; `name` is the canonical label.
13
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
14
+ import { asString } from '../../../lib/format.js';
15
+ import { messageOf } from '../../transport.js';
16
+ /**
17
+ * The most rows one read can return: Drupal's `OffsetPage::SIZE_MAX`, verified
18
+ * against the control plane.
19
+ *
20
+ * Exported because a bag of exactly this length may be a TRUNCATED answer, and
21
+ * only the module that performs the read knows the number. The picker does
22
+ * not currently disclose truncation here (project names are short and a small
23
+ * bag is rare), but the constant is the same shape `parents` and `users`
24
+ * already publish so a future disclosure can find it.
25
+ */
26
+ export const PROJECT_PAGE_SIZE = 50;
27
+ const toCandidate = (item) => {
28
+ const id = item.id ?? '';
29
+ return {
30
+ id,
31
+ name: asString(item.attributes?.name) ?? id,
32
+ };
33
+ };
34
+ /**
35
+ * One page of projects whose `name` contains the typed fragment.
36
+ *
37
+ * `name` is a plain string field on `gaia_project` (no `display_name`
38
+ * computed field), so `filter[name][operator]=CONTAINS` is a real server-side
39
+ * filter — matching is name-only, the same restriction the Who picker's
40
+ * matching carries for the same reason (R4 of GAIA-299). The two-character
41
+ * gate (AC-3 of GAIA-540) is THIS field's policy, encoded in the source that
42
+ * calls this read, NOT here — the data layer returns whatever it was asked
43
+ * for, including an empty array for the empty query.
44
+ *
45
+ * Yields the OUTCOME rather than a swallowed array, so the adapter can record
46
+ * a `projects` error like every other option bag (D4) instead of presenting an
47
+ * empty candidate list as "no projects exist".
48
+ */
49
+ export async function loadProjectCandidates(client, query) {
50
+ const params = new DrupalJsonApiParams();
51
+ if (query)
52
+ params.addFilter('name', query, 'CONTAINS');
53
+ params.addPageLimit(PROJECT_PAGE_SIZE);
54
+ try {
55
+ const doc = (await client.get('gaia_project/gaia_project', params));
56
+ return { rows: (doc?.data ?? []).map(toCandidate) };
57
+ }
58
+ catch (caught) {
59
+ return { rows: [], error: messageOf(caught) };
60
+ }
61
+ }
@@ -13,6 +13,13 @@ export interface WhoCriterion {
13
13
  /** The display name — what the summary names, carried so no lookup is needed. */
14
14
  label: string;
15
15
  }
16
+ /** A picked project: the uuid the query filters by, plus the name the summary names. */
17
+ export interface ProjectCriterion {
18
+ /** The project's uuid — what the query filters by. */
19
+ id: string;
20
+ /** The project name — what the summary names, carried so no lookup is needed. */
21
+ label: string;
22
+ }
16
23
  /** The active search + filter criteria for the project ticket list. */
17
24
  export interface TicketCriteria {
18
25
  query: string;
@@ -35,6 +42,8 @@ export interface TicketCriteria {
35
42
  * exposed-Views facet, which the TUI never queries through.
36
43
  */
37
44
  owner: WhoCriterion | null;
45
+ /** Tickets in this project only (GAIA-540); `null` means "all". */
46
+ project: ProjectCriterion | null;
38
47
  }
39
48
  /** The shipped default: everything unset, closed rows hidden (GAIA-282 D1). */
40
49
  export declare const DEFAULT_TICKET_CRITERIA: TicketCriteria;
@@ -61,6 +70,9 @@ export interface TicketQueryOptions {
61
70
  /** Sideload parent_id + conductor_id for the PARENT/COND columns. Default true;
62
71
  * the parent picker passes false because it renders neither (D8). */
63
72
  includes?: boolean;
73
+ /** Apply criteria.project as a filter. Default false; set true in
74
+ * buildUserTicketQueryParams so My work can narrow by project (GAIA-540). */
75
+ projectFromCriteria?: boolean;
64
76
  }
65
77
  export declare function buildTicketQueryParams(projectId: string, criteria: TicketCriteria, options?: TicketQueryOptions): DrupalJsonApiParams;
66
78
  /**