@gaia-ai/addon-gaia-ui 0.17.2 → 0.19.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 (42) hide show
  1. package/dist/src/Entity/ticket/Component/edit-form/index.js +9 -2
  2. package/dist/src/Entity/ticket/Component/sections/index.js +5 -3
  3. package/dist/src/Entity/ticket/Component/ticket-content/index.d.ts +17 -0
  4. package/dist/src/Entity/ticket/Component/ticket-content/index.js +60 -2
  5. package/dist/src/Entity/ticket/Component/ticket-content/model.d.ts +1 -1
  6. package/dist/src/Entity/ticket/Component/ticket-content/model.js +33 -5
  7. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -2
  8. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +59 -4
  9. package/dist/src/Entity/ticket/Data/addons.d.ts +53 -0
  10. package/dist/src/Entity/ticket/Data/addons.js +35 -0
  11. package/dist/src/Entity/ticket/Data/detail.js +2 -0
  12. package/dist/src/Entity/ticket/Data/store.js +2 -0
  13. package/dist/src/Entity/ticket/Data/tickets.d.ts +13 -9
  14. package/dist/src/Entity/ticket/Data/tickets.js +12 -4
  15. package/dist/src/Entity/ticket/Data/write.d.ts +7 -0
  16. package/dist/src/Entity/ticket/Data/write.js +17 -0
  17. package/dist/src/Entity/ticket/Form/addon-action.d.ts +37 -0
  18. package/dist/src/Entity/ticket/Form/addon-action.js +80 -0
  19. package/dist/src/Entity/ticket/Form/create-data.d.ts +2 -0
  20. package/dist/src/Entity/ticket/Form/create-data.js +9 -1
  21. package/dist/src/Entity/ticket/Form/create.d.ts +5 -1
  22. package/dist/src/Entity/ticket/Form/create.js +3 -0
  23. package/dist/src/Entity/ticket/Form/edit-data.d.ts +2 -0
  24. package/dist/src/Entity/ticket/Form/edit-data.js +14 -1
  25. package/dist/src/Entity/ticket/Form/edit.d.ts +14 -1
  26. package/dist/src/Entity/ticket/Form/edit.js +31 -0
  27. package/dist/src/Entity/ticket/Form/reviewer-options.d.ts +10 -0
  28. package/dist/src/Entity/ticket/Form/reviewer-options.js +48 -0
  29. package/dist/src/Entity/ticket/Screen/conductor-tickets.d.ts +2 -2
  30. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +4 -0
  31. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +30 -1
  32. package/dist/src/Entity/ticket/Screen/ticket-addons.d.ts +94 -0
  33. package/dist/src/Entity/ticket/Screen/ticket-addons.js +492 -0
  34. package/dist/src/Entity/ticket/Screen/ticket-content-screen.d.ts +21 -0
  35. package/dist/src/Entity/ticket/Screen/ticket-content-screen.js +122 -8
  36. package/dist/src/Entity/ticket/Screen/ticket-detail.js +4 -2
  37. package/dist/src/Entity/ticket/Screen/tickets.d.ts +2 -2
  38. package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +2 -2
  39. package/dist/src/Routing/routes.js +3 -0
  40. package/dist/src/Widget/multiselect.js +7 -3
  41. package/dist/src/types.d.ts +5 -0
  42. package/package.json +3 -2
@@ -56,7 +56,7 @@ const WARNING_INDENT = 2;
56
56
  * Parent/Labels are structural (GAIA-360). The duration grammar is named
57
57
  * because the popup has no validation channel: an unparseable entry is
58
58
  * silently left unwritten, so the accepted spelling has to be on screen. */
59
- const LEGEND = 'Assignee = human ownership · Conductor = who works it next · ' +
59
+ const LEGEND = 'Assignee = human ownership · Reviewers = named reviewers · Conductor = who works it next · ' +
60
60
  'Estimate/Spent = accounted time, not the run footprint (1d 2h 30m, 90m, or a bare number as minutes) · ' +
61
61
  'Parent = optional parent ticket · Labels = multi-select from gaia_labels · ' +
62
62
  'Cancel ticket abandons a non-terminal ticket (Esc closes and writes nothing)';
@@ -100,7 +100,14 @@ export function editFormTree(fields, s, opts, chrome = {}) {
100
100
  'a conductor';
101
101
  rows.push(...proseNodes(`⚠ ${run.identifier} is being worked on by ${who} (run: ${run.runState}).`, width, 'edit-form-run'), underNode('Reassigning does not stop that run;', width, 'edit-form-run-keeps'), underNode('clearing the conductor stops the NEXT dispatch.', width, 'edit-form-run-next'), textNode({ id: 'edit-form-run-gap', content: '' }));
102
102
  }
103
- rows.push(renderDeclaredFieldsTree(fields, s, opts, { width }));
103
+ const errors = (opts.errors ?? {});
104
+ const reason = (field) => {
105
+ const message = field.from === undefined ? undefined : errors[field.from];
106
+ return message === undefined
107
+ ? undefined
108
+ : `${field.kind === 'multiselect' ? field.label : `${field.label} options`} could not be loaded: ${message}`;
109
+ };
110
+ rows.push(renderDeclaredFieldsTree(fields, s, opts, { width, errorFor: reason }));
104
111
  rows.push(textNode({ id: 'edit-form-gap', content: '' }));
105
112
  rows.push(...proseNodes(LEGEND, width, 'edit-form-legend'));
106
113
  // GAIA-391: the server's message is the whole of this line now. It used to
@@ -8,9 +8,10 @@ const HEADINGS = {
8
8
  // where the ticket SITS in the work hierarchy (context) while links are
9
9
  // outbound artifacts of it. `Description` renders as one collapsible entry (see
10
10
  // `ticket-detail`'s `EXPANDABLE_HEADINGS`), and `Assignment` keeps its GAIA-233
11
- // position immediately after it. GAIA-308: `Time` follows `Assignment` — a
12
- // section no entry lists is invisible in the TTY rendering even though the flat
13
- // `gaia ui GAIA-308 | cat` render still shows it.
11
+ // position immediately after it. Pending reviews sits between Assignment and
12
+ // Time when the current document carries submitted reviews. GAIA-308: `Time`
13
+ // follows that pair — a section no entry lists is invisible in the TTY
14
+ // rendering even though the flat `gaia ui GAIA-308 | cat` render still shows it.
14
15
  // GAIA-369 D3/D4: `Latest summary` sits directly ABOVE `Description` and is
15
16
  // collapsible through the same `EXPANDABLE_HEADINGS` path. Above, because the
16
17
  // newest phase handoff says where the work STANDS while the description says
@@ -28,6 +29,7 @@ const HEADINGS = {
28
29
  'Latest plan',
29
30
  'Description',
30
31
  'Assignment',
32
+ 'Pending reviews',
31
33
  'Time',
32
34
  'Branch',
33
35
  'Environments',
@@ -1,4 +1,5 @@
1
1
  import { type VNode } from '@opentui/core';
2
+ import type { TicketAddons } from '../../Data/addons.js';
2
3
  import { type TicketContentEntry, type TicketContentModel, type TicketContentTab, type TicketProperty, type TicketSidebar } from './model.js';
3
4
  export declare const SIDEBAR_TABS: readonly [{
4
5
  readonly id: "details";
@@ -31,7 +32,23 @@ export interface TicketContentProps {
31
32
  /** The Planning document tab last chosen in this visit. */
32
33
  readonly documentKey?: string;
33
34
  readonly onDocumentTab: (key: string) => void;
35
+ /**
36
+ * The ticket's conductor-run addons (GAIA-661), rendered as a second,
37
+ * unlabeled card below the sidebar — never visible as "addon" wording.
38
+ * Absent (or empty) omits the card entirely.
39
+ */
40
+ readonly addonsPreview?: TicketAddons;
41
+ readonly onOpenAddons?: () => void;
34
42
  }
35
43
  /** The Planning pane's active document: the selection, the visit's tab, or Description. */
36
44
  export declare const activeDocumentKey: (documents: readonly TicketContentEntry[], selectedKey: string | undefined, documentKey: string | undefined) => string | undefined;
45
+ /**
46
+ * The row id a Tab/arrow-key visit lands on for the inline messages card —
47
+ * exported so the screen can add it to `selectableKeys` and open the full
48
+ * send/poll screen on Enter, the same way Details/Stats/Related are reached
49
+ * by keyboard even though their own pane switching stays mouse-only.
50
+ */
51
+ export declare const ADDONS_KEY = "addons-card";
52
+ /** Whether the preview has at least one message worth a keyboard stop. */
53
+ export declare const hasAddonsMessages: (preview: TicketAddons) => boolean;
37
54
  export declare function TicketContent(props: TicketContentProps): VNode;
@@ -357,6 +357,60 @@ const sidebarCard = (id, sidebar, props) => {
357
357
  ],
358
358
  });
359
359
  };
360
+ /**
361
+ * The row id a Tab/arrow-key visit lands on for the inline messages card —
362
+ * exported so the screen can add it to `selectableKeys` and open the full
363
+ * send/poll screen on Enter, the same way Details/Stats/Related are reached
364
+ * by keyboard even though their own pane switching stays mouse-only.
365
+ */
366
+ export const ADDONS_KEY = 'addons-card';
367
+ /** Whether the preview has at least one message worth a keyboard stop. */
368
+ export const hasAddonsMessages = (preview) => preview.addons.some((addon) => addon.messages.length > 0);
369
+ /**
370
+ * The ticket's conductor-run messages (GAIA-661): one pressable line per
371
+ * message, opening the full send/poll screen, and the requester's own latest
372
+ * answer as a one-line preview. Nothing here ever reads "addon".
373
+ */
374
+ const addonsCard = (id, preview, onOpen, selected) => {
375
+ if (preview.addons.length === 0)
376
+ return null;
377
+ const lines = [];
378
+ for (const addon of preview.addons) {
379
+ for (const message of addon.messages) {
380
+ lines.push(paragraphNode({
381
+ id: `${id}-${addon.name}-${message.key}`,
382
+ runs: [plainRun(message.label)],
383
+ fg: FOREGROUND_HIGHLIGHT,
384
+ onMouseDown: onOpen,
385
+ }));
386
+ }
387
+ const result = addon.latest?.result;
388
+ const answer = addon.contract === 2 && result && 'markdown' in result
389
+ ? result.markdown.split('\n')[0]
390
+ : undefined;
391
+ // The same wording as the Web card: never another requester's answer.
392
+ const markdown = addon.messages.length > 0 && !addon.latest
393
+ ? 'You have not sent a message to this addon.'
394
+ : answer === ''
395
+ ? 'Completed without a response.'
396
+ : answer;
397
+ if (markdown)
398
+ lines.push(paragraphNode({
399
+ id: `${id}-${addon.name}-latest`,
400
+ runs: [plainRun(markdown, DIM)],
401
+ fg: FOREGROUND,
402
+ }));
403
+ }
404
+ if (lines.length === 0)
405
+ return null;
406
+ return Card({
407
+ id: ticketContentRowId(ADDONS_KEY),
408
+ width: '100%',
409
+ paddingY: 1,
410
+ borderColor: selected ? FOREGROUND_HIGHLIGHT : BORDER,
411
+ children: lines,
412
+ });
413
+ };
360
414
  const linksSection = (id, links, activateProperty) => {
361
415
  // Drop the `none` placeholder linkProperties emits for a linkless ticket, so
362
416
  // the section is omitted entirely rather than painting "links : none".
@@ -380,9 +434,13 @@ const overview = (id, props) => {
380
434
  const wide = props.terminalColumns >= WIDE_OVERVIEW_COLUMNS;
381
435
  const main = model.entries.filter((entry) => entry.key !== TECHNICAL_KEY);
382
436
  const links = linksSection(`${id}-links`, model.sidebar?.links ?? [], props.activateProperty);
383
- const side = model.sidebar === undefined
437
+ const sidebar = model.sidebar === undefined
384
438
  ? null
385
439
  : sidebarCard(`${id}-sidebar`, model.sidebar, props);
440
+ const addons = props.addonsPreview
441
+ ? addonsCard(`${id}-addons`, props.addonsPreview, props.onOpenAddons, props.selectedKey === ADDONS_KEY)
442
+ : null;
443
+ const side = sidebar === null && addons === null ? null : [sidebar, addons];
386
444
  const sideBySide = wide && side !== null;
387
445
  return h(BoxRenderable, {
388
446
  id,
@@ -401,7 +459,7 @@ const overview = (id, props) => {
401
459
  }, overviewDocuments(`${id}-documents`, main, props), ...(links === null ? [] : [links])), ...(side === null
402
460
  ? []
403
461
  : [
404
- column(`${id}-side`, { width: sideBySide ? '30%' : '100%', flexShrink: 0 }, side),
462
+ column(`${id}-side`, { width: sideBySide ? '30%' : '100%', flexShrink: 0, gap: 1 }, ...side.filter((node) => node !== null)),
405
463
  ]));
406
464
  };
407
465
  export function TicketContent(props) {
@@ -52,7 +52,7 @@ export interface TicketContentModel {
52
52
  export type TicketContentTab = 'overview' | 'planning' | 'comments';
53
53
  export declare const documentTabLabel: (title: string) => string;
54
54
  export declare const FOOTPRINT_COLUMNS: readonly TicketMatrixColumn[];
55
- export declare function ticketContentModel(document: TicketDetailDocument, tab: TicketContentTab, labels: AssignmentLabels | undefined, nowSeconds: number): TicketContentModel;
55
+ export declare function ticketContentModel(document: TicketDetailDocument, tab: TicketContentTab, labels: AssignmentLabels | undefined, nowSeconds: number, baseUrl?: string): TicketContentModel;
56
56
  export declare function ticketContentRowId(key: string): string;
57
57
  export declare function resolveSelectedKey(selectableKeys: readonly string[], previous: string | undefined): string | undefined;
58
58
  export declare function retainExpandedKeys(keys: readonly string[], previous: ReadonlySet<string>): Set<string>;
@@ -23,7 +23,7 @@ const PLANNING_DOCUMENTS = [
23
23
  ];
24
24
  const isBlankLine = (line) => lineText(line).trim() === '';
25
25
  const isBlankBody = (lines) => lines.length === 0 || lines.every(isBlankLine);
26
- const sectionByHeading = (document, labels, nowSeconds) => new Map(sectionsForTab(document, OVERVIEW_TAB, labels, nowSeconds).map((section) => [section.heading, section]));
26
+ const sectionByHeading = (document, labels, nowSeconds, baseUrl) => new Map(sectionsForTab(document, OVERVIEW_TAB, labels, nowSeconds, baseUrl).map((section) => [section.heading, section]));
27
27
  const contentEntry = (key, title, expandable, collapsed, expanded, metadata) => metadata === undefined
28
28
  ? { key, title, expandable, collapsed, expanded }
29
29
  : { key, title, metadata, expandable, collapsed, expanded };
@@ -94,6 +94,23 @@ const lifecycleProperties = (document) => {
94
94
  })()),
95
95
  property('closed', attributes.closed === true ? 'yes' : 'no'),
96
96
  ];
97
+ const frozenAtRaw = attributes.frozen_at;
98
+ const frozenAt = asString(frozenAtRaw) ??
99
+ (typeof frozenAtRaw === 'number' && frozenAtRaw > 0
100
+ ? new Date(frozenAtRaw * 1000).toISOString()
101
+ : undefined);
102
+ if (attributes.frozen === true) {
103
+ extras.push(property('frozen', 'yes'));
104
+ if (frozenAt !== undefined)
105
+ extras.push(property('frozen at', frozenAt));
106
+ }
107
+ const stall = asString(attributes.stall_reason);
108
+ if (stall !== undefined) {
109
+ // `property()` treats values that start with "no " as empty states
110
+ // (`No conductor.` → none). `no conductor` is a real stall reason, so
111
+ // the value is kept verbatim.
112
+ extras.push({ key: 'stall', value: stall });
113
+ }
97
114
  const createdAt = asString(attributes.created);
98
115
  if (createdAt !== undefined)
99
116
  extras.push(property('created at', createdAt));
@@ -105,6 +122,7 @@ const lifecycleProperties = (document) => {
105
122
  const technicalProperties = (byHeading, document, labels) => {
106
123
  const assignment = byHeading.get('Assignment')?.lines ?? [];
107
124
  const assigned = [];
125
+ const reviewers = [];
108
126
  const conductor = [];
109
127
  for (const line of assignment) {
110
128
  const text = lineText(line).trim();
@@ -112,6 +130,10 @@ const technicalProperties = (byHeading, document, labels) => {
112
130
  assigned.push(property('assigned', ''));
113
131
  else if (text.startsWith('Assignee:'))
114
132
  assigned.push(property('assigned', colonValue(text)));
133
+ else if (text.startsWith('Reviewers:')) {
134
+ const value = colonValue(text);
135
+ reviewers.push(property('reviewers', value === '—' ? '' : value));
136
+ }
115
137
  else if (text === 'No conductor.')
116
138
  conductor.push(property('conductor', ''));
117
139
  else if (text.startsWith('Conductor:'))
@@ -153,6 +175,7 @@ const technicalProperties = (byHeading, document, labels) => {
153
175
  return [
154
176
  property('parent', parentText, parentId === undefined ? {} : { ticketId: parentId }),
155
177
  ...(assigned.length > 0 ? assigned : [property('assigned', '')]),
178
+ ...(reviewers.length > 0 ? reviewers : [property('reviewers', '')]),
156
179
  property('created', headerCreatedBy(document, labels)),
157
180
  ...lifecycleProperties(document),
158
181
  ...branch,
@@ -387,13 +410,18 @@ const hasSummary = (lines) => {
387
410
  .filter((line) => line !== '');
388
411
  return text.length > 0 && text.some((line) => line !== EMPTY_SUMMARY);
389
412
  };
390
- const overviewModel = (document, labels, nowSeconds) => {
391
- const byHeading = sectionByHeading(document, labels, nowSeconds);
413
+ const overviewModel = (document, labels, nowSeconds, baseUrl) => {
414
+ const byHeading = sectionByHeading(document, labels, nowSeconds, baseUrl);
392
415
  const summaryLines = byHeading.get('Latest summary')?.lines ?? [];
393
416
  const documents = [];
394
417
  if (hasSummary(summaryLines)) {
395
418
  documents.push(contentEntry(sectionKey('Latest summary'), 'Latest summary', true, firstLineSummary(summaryLines.map(styledOf)), summaryLines));
396
419
  }
420
+ // Shown only to the current assignee, whose detail read carries the queue.
421
+ const pendingLines = byHeading.get('Pending reviews')?.lines ?? [];
422
+ if (pendingLines.length > 0) {
423
+ documents.push(contentEntry(sectionKey('Pending reviews'), 'Pending reviews', true, pendingLines.slice(0, 1), pendingLines));
424
+ }
397
425
  documents.push(described('Description', byHeading.get('Description')?.lines ?? ['No description.'], 'No description.'));
398
426
  const details = technicalProperties(byHeading, document, labels);
399
427
  const technical = asLines(details);
@@ -439,9 +467,9 @@ const commentsModel = (document, nowSeconds) => commentItems(document).map((item
439
467
  : item.bodyLines;
440
468
  return contentEntry(`comment:${item.id}`, item.type, true, blank ? ['No comment content.'] : [], expanded, `${item.author || '—'} · ${relativeCreated(item.created, nowSeconds)}`);
441
469
  });
442
- export function ticketContentModel(document, tab, labels, nowSeconds) {
470
+ export function ticketContentModel(document, tab, labels, nowSeconds, baseUrl) {
443
471
  if (tab === 'overview') {
444
- const { entries, sidebar } = overviewModel(document, labels, nowSeconds);
472
+ const { entries, sidebar } = overviewModel(document, labels, nowSeconds, baseUrl);
445
473
  return {
446
474
  entries,
447
475
  selectableKeys: entries.map((entry) => entry.key),
@@ -55,6 +55,10 @@ export interface AssignmentLabels {
55
55
  id: string;
56
56
  label: string;
57
57
  }[];
58
+ reviewers?: {
59
+ id: string;
60
+ label: string;
61
+ }[];
58
62
  }
59
63
  /**
60
64
  * The ticket's assignee as ONE row of text — the header box's Assigned row.
@@ -157,9 +161,9 @@ export declare function headerState(document: TicketDetailDocument): string;
157
161
  * it is optional so every pre-existing caller and fixture is unchanged, and the
158
162
  * kernel passes the real clock.
159
163
  */
160
- export declare function detailSections(document: TicketDetailDocument, labels?: AssignmentLabels, nowSeconds?: number): Section[];
164
+ export declare function detailSections(document: TicketDetailDocument, labels?: AssignmentLabels, nowSeconds?: number, baseUrl?: string): Section[];
161
165
  /** The detailSections that belong to a given (non-comments) tab, in tab order. */
162
- export declare function sectionsForTab(document: TicketDetailDocument, tabIndex: number, labels?: AssignmentLabels, nowSeconds?: number): Section[];
166
+ export declare function sectionsForTab(document: TicketDetailDocument, tabIndex: number, labels?: AssignmentLabels, nowSeconds?: number, baseUrl?: string): Section[];
163
167
  /**
164
168
  * One item per comment for the collapsible Comments tab: type, author, created
165
169
  * (raw ISO — the plugin formats the relative age), the body RENDERED into
@@ -156,11 +156,46 @@ function compareCommentsNewestFirst(a, b) {
156
156
  * tickets) and shared with those consumers, so an in-place sort would be an
157
157
  * invisible side effect on someone else's input.
158
158
  */
159
+ const COMMENT_TYPES = new Set([
160
+ 'gaia_comment--gaia_comment',
161
+ 'gaia_comment--review',
162
+ ]);
163
+ function isCommentResource(resource) {
164
+ return typeof resource.type === 'string' && COMMENT_TYPES.has(resource.type);
165
+ }
159
166
  function commentResources(document) {
167
+ const included = (document.included ?? []).filter(isCommentResource);
168
+ const linkage = document.data?.relationships?.comments?.data;
169
+ const comments = linkage === undefined
170
+ ? included
171
+ : included.filter((r) => new Set(toManyIds(document, 'comments')).has(r.id ?? ''));
172
+ return comments.sort(compareCommentsNewestFirst);
173
+ }
174
+ function pendingReviewResources(document) {
175
+ const linked = new Set(toManyIds(document, 'pending_reviews'));
160
176
  return (document.included ?? [])
161
- .filter((r) => r.type === 'gaia_comment--gaia_comment')
177
+ .filter((r) => r.type === 'gaia_comment--review' && linked.has(r.id ?? ''))
162
178
  .sort(compareCommentsNewestFirst);
163
179
  }
180
+ function pendingReviewLines(document, baseUrl) {
181
+ const pending = pendingReviewResources(document);
182
+ if (!pending.length)
183
+ return [];
184
+ const web = ticketUrl(baseUrl, document.data?.id);
185
+ return [
186
+ `${pending.length} review${pending.length === 1 ? '' : 's'} awaiting acceptance`,
187
+ ...pending.map((r) => {
188
+ const author = asString(r.attributes?.author_name) ??
189
+ asString(r.attributes?.author) ??
190
+ 'unknown';
191
+ const state = asString(r.attributes?.review_state) ?? 'submitted';
192
+ return `— ${author} · ${state}`;
193
+ }),
194
+ web
195
+ ? `Open in Web to accept or reject: ${web}`
196
+ : 'Open this ticket in Web to accept or reject.',
197
+ ];
198
+ }
164
199
  /**
165
200
  * The `expandedComments` set a freshly opened ticket detail starts with: the
166
201
  * newest comment expanded, every older one collapsed.
@@ -272,8 +307,20 @@ export function headerCreatedBy(document, labels) {
272
307
  knownLabel(labels?.users, userId) ??
273
308
  userId);
274
309
  }
310
+ function reviewerName(document, id, labels) {
311
+ const user = included(document, 'user--user', id);
312
+ return (asString(user?.attributes?.display_name) ??
313
+ asString(user?.attributes?.name) ??
314
+ knownLabel(labels?.reviewers, id) ??
315
+ knownLabel(labels?.users, id) ??
316
+ id);
317
+ }
275
318
  function assignmentLines(document, labels) {
276
319
  const assignee = assigneeName(document, labels);
320
+ const reviewerIds = toManyIds(document, 'reviewers');
321
+ const reviewerLabel = reviewerIds.length
322
+ ? reviewerIds.map((id) => reviewerName(document, id, labels)).join(', ')
323
+ : '—';
277
324
  const conductorId = relId(document, 'conductor_id');
278
325
  const conductor = included(document, 'gaia_conductor--gaia_conductor', conductorId);
279
326
  const conductorLabel = asString(conductor?.attributes?.label) ??
@@ -282,6 +329,7 @@ function assignmentLines(document, labels) {
282
329
  conductorId;
283
330
  return [
284
331
  assignee ? `Assignee: ${assignee}` : 'Unassigned.',
332
+ `Reviewers: ${reviewerLabel}`,
285
333
  conductorLabel ? `Conductor: ${conductorLabel}` : 'No conductor.',
286
334
  ];
287
335
  }
@@ -596,7 +644,7 @@ function timeLines(a) {
596
644
  * it is optional so every pre-existing caller and fixture is unchanged, and the
597
645
  * kernel passes the real clock.
598
646
  */
599
- export function detailSections(document, labels, nowSeconds = 0) {
647
+ export function detailSections(document, labels, nowSeconds = 0, baseUrl) {
600
648
  const a = document.data?.attributes ?? {};
601
649
  const sections = [];
602
650
  // GAIA-369 D1/D7: `State` LEADS, and no tab lists it — the same arrangement
@@ -648,6 +696,10 @@ export function detailSections(document, labels, nowSeconds = 0) {
648
696
  heading: 'Assignment',
649
697
  lines: assignmentLines(document, labels),
650
698
  });
699
+ const pendingLines = pendingReviewLines(document, baseUrl);
700
+ if (pendingLines.length) {
701
+ sections.push({ heading: 'Pending reviews', lines: pendingLines });
702
+ }
651
703
  // GAIA-308 AC-9: an overrun has to be visible where the pair is read. Right
652
704
  // after Assignment, and deliberately ABOVE Runs — the accounted number is
653
705
  // read first, the machine footprint that informed it second.
@@ -716,12 +768,15 @@ export function detailSections(document, labels, nowSeconds = 0) {
716
768
  return sections;
717
769
  }
718
770
  /** The detailSections that belong to a given (non-comments) tab, in tab order. */
719
- export function sectionsForTab(document, tabIndex, labels, nowSeconds = 0) {
771
+ export function sectionsForTab(document, tabIndex, labels, nowSeconds = 0, baseUrl) {
720
772
  const tab = TICKET_SECTIONS[tabIndex];
721
773
  const headings = tab?.headings ?? [];
722
774
  if (!headings.length)
723
775
  return [];
724
- const byHeading = new Map(detailSections(document, labels, nowSeconds).map((s) => [s.heading, s]));
776
+ const byHeading = new Map(detailSections(document, labels, nowSeconds, baseUrl).map((s) => [
777
+ s.heading,
778
+ s,
779
+ ]));
725
780
  return headings
726
781
  .map((h) => byHeading.get(h))
727
782
  .filter((s) => s !== undefined);
@@ -0,0 +1,53 @@
1
+ import type { MessageInput, MessageResult } from '@gaia-ai/message';
2
+ import type { DropshClient } from '../../../types.js';
3
+ export interface AddonLatest {
4
+ status: string;
5
+ result: MessageResult | null;
6
+ changed: number;
7
+ }
8
+ /** A contract-2 message entry (GAIA-661). */
9
+ export interface AddonMessageEntry {
10
+ key: string;
11
+ label: string;
12
+ input: MessageInput | null;
13
+ access: string;
14
+ /** Whether the viewer holds the ticket operation the message requires. */
15
+ access_ok: boolean;
16
+ }
17
+ export interface TicketAddon {
18
+ id: string;
19
+ name: string;
20
+ version: string;
21
+ contract: number;
22
+ label: string;
23
+ messages: AddonMessageEntry[];
24
+ /**
25
+ * This requester's own newest message for the ticket (GAIA-661), never
26
+ * another requester's — the server already selects it.
27
+ */
28
+ latest: (AddonLatest & {
29
+ action: string;
30
+ label: string;
31
+ }) | null;
32
+ }
33
+ export interface TicketAddons {
34
+ conductor: {
35
+ status: string;
36
+ } | null;
37
+ addons: TicketAddon[];
38
+ }
39
+ /** One selectable line of an addon panel. */
40
+ export type AddonEntry = {
41
+ kind: 'message';
42
+ id: string;
43
+ message: AddonMessageEntry;
44
+ };
45
+ export declare function readTicketAddons(client: Pick<DropshClient, 'get'>, ticketUuid: string): Promise<TicketAddons>;
46
+ export declare function sendAddonAction(client: Pick<DropshClient, 'post'>, ticketUuid: string, messageId: string, payload: Record<string, unknown>): Promise<void>;
47
+ /**
48
+ * Whether the addon's newest message is still pending or running. Messages
49
+ * are answered in order, so no older one can still be open.
50
+ */
51
+ export declare function isOpen(addon: TicketAddon): boolean;
52
+ /** The panel's lines: one entry per permitted message, always. */
53
+ export declare function addonEntries(addon: TicketAddon): AddonEntry[];
@@ -0,0 +1,35 @@
1
+ // The ticket's addons as the server reads them for a viewer
2
+ // (`GET gaia/addons/ticket/{uuid}`), and the one write: sending a message
3
+ // through the conductor inbox (`POST gaia/inbox/send`). The TUI renders the
4
+ // same definitions and states as the web card, so a reviewer's `gaia ui`
5
+ // needs no addon installed. Contract 1 (views/actions/rows, GAIA-661) is
6
+ // retired; only contract-2 messages are live.
7
+ export async function readTicketAddons(client, ticketUuid) {
8
+ const doc = (await client.get(`gaia/addons/ticket/${ticketUuid}`));
9
+ const attrs = doc?.data?.attributes;
10
+ return { conductor: attrs?.conductor ?? null, addons: attrs?.addons ?? [] };
11
+ }
12
+ export async function sendAddonAction(client, ticketUuid, messageId, payload) {
13
+ await client.post('gaia/inbox/send', {
14
+ data: {
15
+ attributes: { ticket: ticketUuid, message_id: messageId, payload },
16
+ },
17
+ });
18
+ }
19
+ /**
20
+ * Whether the addon's newest message is still pending or running. Messages
21
+ * are answered in order, so no older one can still be open.
22
+ */
23
+ export function isOpen(addon) {
24
+ return (addon.latest?.status === 'pending' || addon.latest?.status === 'running');
25
+ }
26
+ /** The panel's lines: one entry per permitted message, always. */
27
+ export function addonEntries(addon) {
28
+ return addon.messages
29
+ .filter((message) => message.access_ok)
30
+ .map((message) => ({
31
+ kind: 'message',
32
+ id: `message:${message.key}`,
33
+ message,
34
+ }));
35
+ }
@@ -55,6 +55,8 @@ export function loadTicketDetail(client, ticketId, includes = [
55
55
  'blocked_by',
56
56
  'team',
57
57
  'owner_user_id',
58
+ 'reviewers',
59
+ 'pending_reviews',
58
60
  ]) {
59
61
  return client.get(`gaia_ticket/gaia_ticket/${ticketId}?include=${includes.join(',')}`);
60
62
  }
@@ -31,6 +31,8 @@ const allIncludes = [
31
31
  'runs',
32
32
  'team',
33
33
  'owner_user_id',
34
+ 'reviewers',
35
+ 'pending_reviews',
34
36
  ];
35
37
  const ids = (data) => (Array.isArray(data) ? data : data ? [data] : []).map((item) => item.id);
36
38
  /** Preserve omitted fields; explicit relationship replacement removes old rows. */
@@ -23,6 +23,10 @@ export interface TicketRow {
23
23
  projectId: string | null;
24
24
  /** Boolean closed flag; an absent attribute reads as open (spec D5). */
25
25
  closed: boolean;
26
+ /** Operator hold; an absent attribute reads as not frozen (GAIA-302). */
27
+ frozen: boolean;
28
+ /** Why the ticket is standing still: frozen|blocked|no conductor|failing, else ''. */
29
+ stallReason: string;
26
30
  }
27
31
  /** A `(id) → resource` lookup over a document's sideloaded `included` entries.
28
32
  * JSON:API ids are UUIDs (unique across bundles), so id alone keys uniquely. */
@@ -83,8 +87,8 @@ export declare const TICKET_COLUMNS: readonly [{
83
87
  }, {
84
88
  readonly key: "run";
85
89
  readonly label: "RUN";
86
- readonly width: 8;
87
- readonly fields: readonly ["derived_status"];
90
+ readonly width: 12;
91
+ readonly fields: readonly ["derived_status", "stall_reason"];
88
92
  readonly get: (r: TicketRow) => string;
89
93
  }, {
90
94
  readonly key: "parent";
@@ -153,8 +157,8 @@ export declare const TICKET_TABLE: {
153
157
  }, {
154
158
  readonly key: "run";
155
159
  readonly label: "RUN";
156
- readonly width: 8;
157
- readonly fields: readonly ["derived_status"];
160
+ readonly width: 12;
161
+ readonly fields: readonly ["derived_status", "stall_reason"];
158
162
  readonly get: (r: TicketRow) => string;
159
163
  }, {
160
164
  readonly key: "parent";
@@ -217,8 +221,8 @@ export declare const CONDUCTOR_TICKET_COLUMNS: readonly [{
217
221
  }, {
218
222
  readonly key: "run";
219
223
  readonly label: "RUN";
220
- readonly width: 8;
221
- readonly fields: readonly ["derived_status"];
224
+ readonly width: 12;
225
+ readonly fields: readonly ["derived_status", "stall_reason"];
222
226
  readonly get: (r: TicketRow) => string;
223
227
  }, {
224
228
  readonly key: "conductor";
@@ -264,8 +268,8 @@ export declare const CONDUCTOR_TICKET_TABLE: {
264
268
  }, {
265
269
  readonly key: "run";
266
270
  readonly label: "RUN";
267
- readonly width: 8;
268
- readonly fields: readonly ["derived_status"];
271
+ readonly width: 12;
272
+ readonly fields: readonly ["derived_status", "stall_reason"];
269
273
  readonly get: (r: TicketRow) => string;
270
274
  }, {
271
275
  readonly key: "conductor";
@@ -292,7 +296,7 @@ export declare const CONDUCTOR_TICKET_TABLE: {
292
296
  * down to the bytes on the wire. Column order would emit `title` last (it rides
293
297
  * the trailing TITLE column) instead of second.
294
298
  */
295
- export declare const TICKET_LIST_ATTRIBUTES: readonly ["identifier", "title", "state", "priority", "assignee_initials", "owner_initials", "derived_status", "workflow", "closed"];
299
+ export declare const TICKET_LIST_ATTRIBUTES: readonly ["identifier", "title", "state", "priority", "assignee_initials", "owner_initials", "derived_status", "stall_reason", "workflow", "closed", "frozen"];
296
300
  /** What `addFields('gaia_ticket--gaia_ticket', …)` takes. */
297
301
  export declare const TICKET_LIST_FIELDS: string[];
298
302
  /** The sideloaded conductor renders one column: its label. */
@@ -57,7 +57,7 @@ export const TICKET_LIST_RELATIONSHIPS = [
57
57
  * columns' union PLUS this list, and this list is an explicit declaration rather
58
58
  * than an unstated remainder.
59
59
  */
60
- const TICKET_PREDICATE_ATTRIBUTES = ['workflow', 'closed'];
60
+ const TICKET_PREDICATE_ATTRIBUTES = ['workflow', 'closed', 'frozen'];
61
61
  /**
62
62
  * The ticket list's columns — the header, the width, the cell value AND the
63
63
  * fieldset entry of each, in ONE declaration (GAIA-294 AC 4).
@@ -125,9 +125,13 @@ export const TICKET_COLUMNS = [
125
125
  {
126
126
  key: 'run',
127
127
  label: 'RUN',
128
- width: 8,
129
- fields: ['derived_status'],
130
- get: (r) => r.derivedStatus,
128
+ width: 12,
129
+ // `stall_reason` rides this column: GAIA-302 shows WHY a ticket is standing
130
+ // still exactly where it would otherwise show what it is doing, so the two
131
+ // share one cell and therefore one fieldset entry. Width 12 fits
132
+ // `no conductor`, the longest operator-facing reason.
133
+ fields: ['derived_status', 'stall_reason'],
134
+ get: (r) => r.stallReason || r.derivedStatus,
131
135
  },
132
136
  {
133
137
  key: 'parent',
@@ -220,8 +224,10 @@ export const TICKET_LIST_ATTRIBUTES = [
220
224
  'assignee_initials',
221
225
  'owner_initials',
222
226
  'derived_status',
227
+ 'stall_reason',
223
228
  'workflow',
224
229
  'closed',
230
+ 'frozen',
225
231
  ];
226
232
  /** What `addFields('gaia_ticket--gaia_ticket', …)` takes. */
227
233
  export const TICKET_LIST_FIELDS = [
@@ -265,6 +271,7 @@ export function mapTicketData(item, included) {
265
271
  assigneeInitials: str('assignee_initials') ?? '',
266
272
  ownerInitials: str('owner_initials') ?? '',
267
273
  derivedStatus: str('derived_status') ?? 'idle',
274
+ stallReason: str('stall_reason') ?? '',
268
275
  labels,
269
276
  type: str('workflow') ?? null,
270
277
  parent,
@@ -272,6 +279,7 @@ export function mapTicketData(item, included) {
272
279
  assigneeId: singleRelId(rel('assignee_user_id')),
273
280
  projectId: singleRelId(rel('project_id')),
274
281
  closed: typeof raw('closed') === 'boolean' ? raw('closed') : false,
282
+ frozen: typeof raw('frozen') === 'boolean' ? raw('frozen') : false,
275
283
  };
276
284
  }
277
285
  /**
@@ -11,6 +11,13 @@ export declare const TICKET_TYPE = "gaia_ticket--gaia_ticket";
11
11
  * silent failure that criterion forbids.
12
12
  */
13
13
  export declare function updateTicketAssignment(client: DropshClient | undefined, ticketId: string, body: ResourceWriteBody): Promise<unknown>;
14
+ /**
15
+ * PATCH a ticket's `frozen` flag (GAIA-302).
16
+ *
17
+ * Same write path as assignment: a refusal is not caught here so the caller
18
+ * can put the server's words on the status line.
19
+ */
20
+ export declare function updateTicketFrozen(client: DropshClient | undefined, ticketId: string, frozen: boolean): Promise<unknown>;
14
21
  /** The text format every ticket description is stored in (`gaia_rich`). */
15
22
  export declare const TICKET_DESCRIPTION_FORMAT = "gaia_rich";
16
23
  /**