@gaia-ai/addon-gaia-ui 0.18.0 → 0.20.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.
- package/dist/src/Entity/conductor/Data/conductors.d.ts +10 -0
- package/dist/src/Entity/conductor/Data/conductors.js +23 -1
- package/dist/src/Entity/ticket/Component/create-form/index.d.ts +2 -2
- package/dist/src/Entity/ticket/Component/create-form/index.js +4 -4
- package/dist/src/Entity/ticket/Component/edit-form/index.js +9 -2
- package/dist/src/Entity/ticket/Component/sections/index.js +5 -3
- package/dist/src/Entity/ticket/Component/ticket-content/index.d.ts +17 -0
- package/dist/src/Entity/ticket/Component/ticket-content/index.js +60 -2
- package/dist/src/Entity/ticket/Component/ticket-content/model.d.ts +1 -1
- package/dist/src/Entity/ticket/Component/ticket-content/model.js +16 -15
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -2
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +61 -6
- package/dist/src/Entity/ticket/Data/addons.d.ts +53 -0
- package/dist/src/Entity/ticket/Data/addons.js +35 -0
- package/dist/src/Entity/ticket/Data/detail.js +2 -1
- package/dist/src/Entity/ticket/Data/store.js +2 -1
- package/dist/src/Entity/ticket/Data/write.js +8 -0
- package/dist/src/Entity/ticket/Form/addon-action.d.ts +37 -0
- package/dist/src/Entity/ticket/Form/addon-action.js +80 -0
- package/dist/src/Entity/ticket/Form/create-data.d.ts +12 -1
- package/dist/src/Entity/ticket/Form/create-data.js +56 -17
- package/dist/src/Entity/ticket/Form/create.d.ts +49 -3
- package/dist/src/Entity/ticket/Form/create.js +82 -45
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +2 -0
- package/dist/src/Entity/ticket/Form/edit-data.js +14 -1
- package/dist/src/Entity/ticket/Form/edit.d.ts +14 -1
- package/dist/src/Entity/ticket/Form/edit.js +31 -0
- package/dist/src/Entity/ticket/Form/reviewer-options.d.ts +10 -0
- package/dist/src/Entity/ticket/Form/reviewer-options.js +48 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +24 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +150 -18
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +30 -1
- package/dist/src/Entity/ticket/Screen/ticket-addons.d.ts +94 -0
- package/dist/src/Entity/ticket/Screen/ticket-addons.js +492 -0
- package/dist/src/Entity/ticket/Screen/ticket-content-screen.d.ts +14 -0
- package/dist/src/Entity/ticket/Screen/ticket-content-screen.js +69 -7
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +6 -5
- package/dist/src/Routing/routes.js +3 -0
- package/dist/src/Widget/multiselect.js +7 -3
- package/dist/src/types.d.ts +5 -0
- package/package.json +3 -2
|
@@ -26,6 +26,16 @@ export interface Conductor {
|
|
|
26
26
|
* contract, because it already answers a failure by falling back.
|
|
27
27
|
*/
|
|
28
28
|
export declare const listConductors: (client: DropshClient) => Promise<ListOutcome<Conductor>>;
|
|
29
|
+
/**
|
|
30
|
+
* The conductor collection scoped to one project (GAIA-664 review F4).
|
|
31
|
+
*
|
|
32
|
+
* The create popup's narrow option loader, not `ConductorQuery`'s row-page
|
|
33
|
+
* read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
|
|
34
|
+
* outcome/error contract rather than inventing a second one for the same
|
|
35
|
+
* entity. An unscoped caller keeps calling {@link listConductors}; this is
|
|
36
|
+
* additive.
|
|
37
|
+
*/
|
|
38
|
+
export declare const listConductorsForProject: (client: DropshClient, projectId: string) => Promise<ListOutcome<Conductor>>;
|
|
29
39
|
/**
|
|
30
40
|
* The same collection read through the WRITE client where there is one — still just
|
|
31
41
|
* reading, but the write scope is the one that decides what a write may reference,
|
|
@@ -8,7 +8,7 @@ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
|
8
8
|
import { humanizeAge } from '../../../lib/format.js';
|
|
9
9
|
import { loadPage, } from '../../collection.js';
|
|
10
10
|
import { LIST_PAGE_SIZE } from '../../cycle.js';
|
|
11
|
-
import { listOutcome, listPreferringWrite, } from '../../transport.js';
|
|
11
|
+
import { listOutcome, listPreferringWrite, messageOf, } from '../../transport.js';
|
|
12
12
|
import { deriveConductorStatus } from './liveness.js';
|
|
13
13
|
const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
14
14
|
const toConductor = (r) => ({
|
|
@@ -32,6 +32,28 @@ const toConductor = (r) => ({
|
|
|
32
32
|
* contract, because it already answers a failure by falling back.
|
|
33
33
|
*/
|
|
34
34
|
export const listConductors = (client) => listOutcome(client, 'gaia_conductor', toConductor);
|
|
35
|
+
/**
|
|
36
|
+
* The conductor collection scoped to one project (GAIA-664 review F4).
|
|
37
|
+
*
|
|
38
|
+
* The create popup's narrow option loader, not `ConductorQuery`'s row-page
|
|
39
|
+
* read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
|
|
40
|
+
* outcome/error contract rather than inventing a second one for the same
|
|
41
|
+
* entity. An unscoped caller keeps calling {@link listConductors}; this is
|
|
42
|
+
* additive.
|
|
43
|
+
*/
|
|
44
|
+
export const listConductorsForProject = async (client, projectId) => {
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
rows: (await client
|
|
48
|
+
.collection('gaia_conductor')
|
|
49
|
+
.where('project_id.id', '=', projectId)
|
|
50
|
+
.list()).map(toConductor),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (caught) {
|
|
54
|
+
return { rows: [], error: messageOf(caught) };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
35
57
|
/**
|
|
36
58
|
* The same collection read through the WRITE client where there is one — still just
|
|
37
59
|
* reading, but the write scope is the one that decides what a write may reference,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@opentui/core';
|
|
2
|
-
import {
|
|
2
|
+
import type { CreateFieldId, CreateFormOptions, CreateFormState, FieldDef } from '../../Form/create.js';
|
|
3
3
|
/** The chrome the kernel wraps this tree in. */
|
|
4
4
|
export interface CreateFormChrome {
|
|
5
5
|
/**
|
|
@@ -25,4 +25,4 @@ export interface CreateFormChrome {
|
|
|
25
25
|
/** The id the popup's body column is rooted at. */
|
|
26
26
|
export declare const CREATE_FORM_REGION = "create-form";
|
|
27
27
|
/** Render the "New ticket" popup as the tree the kernel frames. */
|
|
28
|
-
export declare function createFormTree(s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
|
|
28
|
+
export declare function createFormTree(fields: FieldDef<CreateFieldId>[], s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// to rebase again onto its own.
|
|
18
18
|
import { columnNode, textNode } from '../../../../lib/vnode.js';
|
|
19
19
|
import { note, renderDeclaredFieldsTree, widgetFor, } from '../../../../Widget/index.js';
|
|
20
|
-
import { FIELDS, } from '../../Form/create.js';
|
|
21
20
|
const DEFAULT_WIDTH = 58;
|
|
22
21
|
/** The id the popup's body column is rooted at. */
|
|
23
22
|
export const CREATE_FORM_REGION = 'create-form';
|
|
@@ -34,7 +33,7 @@ const WHOLE_FORM = 'The form options';
|
|
|
34
33
|
*/
|
|
35
34
|
const bagName = (field) => field.kind === 'multiselect' ? field.label : `${field.label} options`;
|
|
36
35
|
/** Render the "New ticket" popup as the tree the kernel frames. */
|
|
37
|
-
export function createFormTree(s, opts, chrome = {}) {
|
|
36
|
+
export function createFormTree(fields, s, opts, chrome = {}) {
|
|
38
37
|
const width = chrome.width ?? DEFAULT_WIDTH;
|
|
39
38
|
// Keyed by BAG, which is what `from` names; the typed shape is the adapter's
|
|
40
39
|
// business, and reading it by key is what keeps this loop declaration-driven.
|
|
@@ -49,7 +48,8 @@ export function createFormTree(s, opts, chrome = {}) {
|
|
|
49
48
|
// one, plus a whole-form failure, collapses into ONE row at the top. It is a
|
|
50
49
|
// note, not a refusal: a failed OPTION read must not cost the writer the
|
|
51
50
|
// description they opened the popup to type (D4).
|
|
52
|
-
const formLevel =
|
|
51
|
+
const formLevel = fields
|
|
52
|
+
.filter((f) => !widgetFor(f).ownsError)
|
|
53
53
|
.map(reason)
|
|
54
54
|
.filter((sentence) => sentence !== undefined);
|
|
55
55
|
if (errors.form !== undefined) {
|
|
@@ -63,7 +63,7 @@ export function createFormTree(s, opts, chrome = {}) {
|
|
|
63
63
|
// `note` supplies the `⚠` sigil itself, so the message is passed plain —
|
|
64
64
|
// and it WRAPS, which is what keeps a long server message off the frame's
|
|
65
65
|
// ellipsis (the same reason the option-load note above it wraps).
|
|
66
|
-
...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(
|
|
66
|
+
...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(fields, s, opts, {
|
|
67
67
|
width,
|
|
68
68
|
errorFor: reason,
|
|
69
69
|
// A form-level failure means no read succeeded, so an empty bag may not
|
|
@@ -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
|
-
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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 };
|
|
@@ -69,10 +69,6 @@ const colonValue = (text) => {
|
|
|
69
69
|
const index = text.indexOf(':');
|
|
70
70
|
return index < 0 ? text : text.slice(index + 1);
|
|
71
71
|
};
|
|
72
|
-
const entityLabel = (document, id, type) => {
|
|
73
|
-
const row = (document.included ?? []).find((resource) => resource.id === id && resource.type === type);
|
|
74
|
-
return (asString(row?.attributes?.label) ?? asString(row?.attributes?.name) ?? id);
|
|
75
|
-
};
|
|
76
72
|
const PRIORITY_LABEL = {
|
|
77
73
|
'1': 'high',
|
|
78
74
|
'2': 'medium',
|
|
@@ -86,12 +82,6 @@ const lifecycleProperties = (document) => {
|
|
|
86
82
|
const priorityKey = String(attributes.priority ?? '').toLowerCase();
|
|
87
83
|
const extras = [
|
|
88
84
|
property('priority', PRIORITY_LABEL[priorityKey] ?? ''),
|
|
89
|
-
property('team', (() => {
|
|
90
|
-
const teamId = relationshipIds(document, 'team')[0];
|
|
91
|
-
return teamId === undefined
|
|
92
|
-
? ''
|
|
93
|
-
: entityLabel(document, teamId, 'gaia_team--gaia_team');
|
|
94
|
-
})()),
|
|
95
85
|
property('closed', attributes.closed === true ? 'yes' : 'no'),
|
|
96
86
|
];
|
|
97
87
|
const frozenAtRaw = attributes.frozen_at;
|
|
@@ -122,6 +112,7 @@ const lifecycleProperties = (document) => {
|
|
|
122
112
|
const technicalProperties = (byHeading, document, labels) => {
|
|
123
113
|
const assignment = byHeading.get('Assignment')?.lines ?? [];
|
|
124
114
|
const assigned = [];
|
|
115
|
+
const reviewers = [];
|
|
125
116
|
const conductor = [];
|
|
126
117
|
for (const line of assignment) {
|
|
127
118
|
const text = lineText(line).trim();
|
|
@@ -129,6 +120,10 @@ const technicalProperties = (byHeading, document, labels) => {
|
|
|
129
120
|
assigned.push(property('assigned', ''));
|
|
130
121
|
else if (text.startsWith('Assignee:'))
|
|
131
122
|
assigned.push(property('assigned', colonValue(text)));
|
|
123
|
+
else if (text.startsWith('Reviewers:')) {
|
|
124
|
+
const value = colonValue(text);
|
|
125
|
+
reviewers.push(property('reviewers', value === '—' ? '' : value));
|
|
126
|
+
}
|
|
132
127
|
else if (text === 'No conductor.')
|
|
133
128
|
conductor.push(property('conductor', ''));
|
|
134
129
|
else if (text.startsWith('Conductor:'))
|
|
@@ -170,6 +165,7 @@ const technicalProperties = (byHeading, document, labels) => {
|
|
|
170
165
|
return [
|
|
171
166
|
property('parent', parentText, parentId === undefined ? {} : { ticketId: parentId }),
|
|
172
167
|
...(assigned.length > 0 ? assigned : [property('assigned', '')]),
|
|
168
|
+
...(reviewers.length > 0 ? reviewers : [property('reviewers', '')]),
|
|
173
169
|
property('created', headerCreatedBy(document, labels)),
|
|
174
170
|
...lifecycleProperties(document),
|
|
175
171
|
...branch,
|
|
@@ -404,13 +400,18 @@ const hasSummary = (lines) => {
|
|
|
404
400
|
.filter((line) => line !== '');
|
|
405
401
|
return text.length > 0 && text.some((line) => line !== EMPTY_SUMMARY);
|
|
406
402
|
};
|
|
407
|
-
const overviewModel = (document, labels, nowSeconds) => {
|
|
408
|
-
const byHeading = sectionByHeading(document, labels, nowSeconds);
|
|
403
|
+
const overviewModel = (document, labels, nowSeconds, baseUrl) => {
|
|
404
|
+
const byHeading = sectionByHeading(document, labels, nowSeconds, baseUrl);
|
|
409
405
|
const summaryLines = byHeading.get('Latest summary')?.lines ?? [];
|
|
410
406
|
const documents = [];
|
|
411
407
|
if (hasSummary(summaryLines)) {
|
|
412
408
|
documents.push(contentEntry(sectionKey('Latest summary'), 'Latest summary', true, firstLineSummary(summaryLines.map(styledOf)), summaryLines));
|
|
413
409
|
}
|
|
410
|
+
// Shown only to the current assignee, whose detail read carries the queue.
|
|
411
|
+
const pendingLines = byHeading.get('Pending reviews')?.lines ?? [];
|
|
412
|
+
if (pendingLines.length > 0) {
|
|
413
|
+
documents.push(contentEntry(sectionKey('Pending reviews'), 'Pending reviews', true, pendingLines.slice(0, 1), pendingLines));
|
|
414
|
+
}
|
|
414
415
|
documents.push(described('Description', byHeading.get('Description')?.lines ?? ['No description.'], 'No description.'));
|
|
415
416
|
const details = technicalProperties(byHeading, document, labels);
|
|
416
417
|
const technical = asLines(details);
|
|
@@ -456,9 +457,9 @@ const commentsModel = (document, nowSeconds) => commentItems(document).map((item
|
|
|
456
457
|
: item.bodyLines;
|
|
457
458
|
return contentEntry(`comment:${item.id}`, item.type, true, blank ? ['No comment content.'] : [], expanded, `${item.author || '—'} · ${relativeCreated(item.created, nowSeconds)}`);
|
|
458
459
|
});
|
|
459
|
-
export function ticketContentModel(document, tab, labels, nowSeconds) {
|
|
460
|
+
export function ticketContentModel(document, tab, labels, nowSeconds, baseUrl) {
|
|
460
461
|
if (tab === 'overview') {
|
|
461
|
-
const { entries, sidebar } = overviewModel(document, labels, nowSeconds);
|
|
462
|
+
const { entries, sidebar } = overviewModel(document, labels, nowSeconds, baseUrl);
|
|
462
463
|
return {
|
|
463
464
|
entries,
|
|
464
465
|
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
|
|
@@ -44,8 +44,8 @@ function headerLineNodes(id, content, fg) {
|
|
|
44
44
|
}
|
|
45
45
|
export function ticketHeaderTree(document, identifier, title, lastRow, labels) {
|
|
46
46
|
// One compact meta line — state · assignee · labels — under the title. The
|
|
47
|
-
// full set (created by,
|
|
48
|
-
//
|
|
47
|
+
// full set (created by, branch, …) lives in the Overview sidebar, so the
|
|
48
|
+
// header stays two lines rather than five. Empty fields drop out.
|
|
49
49
|
const meta = [
|
|
50
50
|
headerState(document),
|
|
51
51
|
headerAssignee(document, labels),
|
|
@@ -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--
|
|
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) => [
|
|
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
|
+
}
|
|
@@ -53,8 +53,9 @@ export function loadTicketDetail(client, ticketId, includes = [
|
|
|
53
53
|
'parent_id',
|
|
54
54
|
'labels',
|
|
55
55
|
'blocked_by',
|
|
56
|
-
'team',
|
|
57
56
|
'owner_user_id',
|
|
57
|
+
'reviewers',
|
|
58
|
+
'pending_reviews',
|
|
58
59
|
]) {
|
|
59
60
|
return client.get(`gaia_ticket/gaia_ticket/${ticketId}?include=${includes.join(',')}`);
|
|
60
61
|
}
|
|
@@ -29,8 +29,9 @@ const allIncludes = [
|
|
|
29
29
|
'sub_tickets',
|
|
30
30
|
'referenced_tickets',
|
|
31
31
|
'runs',
|
|
32
|
-
'team',
|
|
33
32
|
'owner_user_id',
|
|
33
|
+
'reviewers',
|
|
34
|
+
'pending_reviews',
|
|
34
35
|
];
|
|
35
36
|
const ids = (data) => (Array.isArray(data) ? data : data ? [data] : []).map((item) => item.id);
|
|
36
37
|
/** Preserve omitted fields; explicit relationship replacement removes old rows. */
|
|
@@ -69,6 +69,14 @@ export function buildTicketCreateBody(payload, projectId) {
|
|
|
69
69
|
})),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
if (payload.reviewers.length > 0) {
|
|
73
|
+
relationships.reviewers = {
|
|
74
|
+
data: payload.reviewers.map((r) => ({
|
|
75
|
+
type: 'user--user',
|
|
76
|
+
id: r.id,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
72
80
|
const parent = one('gaia_ticket--gaia_ticket', payload.parent?.id);
|
|
73
81
|
if (parent)
|
|
74
82
|
relationships.parent_id = parent;
|