@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
@@ -16,6 +16,15 @@ export function updateTicketAssignment(client, ticketId, body) {
16
16
  }
17
17
  return client.update(TICKET_TYPE, ticketId, body);
18
18
  }
19
+ /**
20
+ * PATCH a ticket's `frozen` flag (GAIA-302).
21
+ *
22
+ * Same write path as assignment: a refusal is not caught here so the caller
23
+ * can put the server's words on the status line.
24
+ */
25
+ export function updateTicketFrozen(client, ticketId, frozen) {
26
+ return updateTicketAssignment(client, ticketId, { attributes: { frozen } });
27
+ }
19
28
  /** The text format every ticket description is stored in (`gaia_rich`). */
20
29
  export const TICKET_DESCRIPTION_FORMAT = 'gaia_rich';
21
30
  /** A JSON:API to-one linkage, or nothing at all. */
@@ -60,6 +69,14 @@ export function buildTicketCreateBody(payload, projectId) {
60
69
  })),
61
70
  };
62
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
+ }
63
80
  const parent = one('gaia_ticket--gaia_ticket', payload.parent?.id);
64
81
  if (parent)
65
82
  relationships.parent_id = parent;
@@ -0,0 +1,37 @@
1
+ import type { MessageInput as AddonInput } from '@gaia-ai/message';
2
+ import type { FieldDecl, Option } from '../../../Form/field.js';
3
+ /** A form field as the TUI renders it: a text line or a select. */
4
+ export interface FieldSpec {
5
+ id: string;
6
+ label: string;
7
+ kind: 'text' | 'select';
8
+ options?: Option[];
9
+ }
10
+ /**
11
+ * The rendering table on a terminal: text lines for strings and numbers, a
12
+ * select for an enum, and yes/no for a boolean (there is no toggle widget).
13
+ * A required field without a default is marked `*`.
14
+ */
15
+ export declare function formFields(input: AddonInput): FieldSpec[];
16
+ /**
17
+ * Typed payload from the form's strings, or the per-field errors. The server
18
+ * validates again; this only spares a round trip for the obvious mistakes.
19
+ */
20
+ export declare function payloadFrom(input: AddonInput, values: Record<string, string | null | undefined>): {
21
+ payload: Record<string, unknown>;
22
+ } | {
23
+ errors: Record<string, string>;
24
+ };
25
+ export type AddonFormValues = Record<string, string | null>;
26
+ type AddonFormOptions = Record<string, Option[]>;
27
+ /** The kernel form for an input: its fields, then the Send row. */
28
+ export declare function addonForm(input: AddonInput): {
29
+ fields: FieldDecl[];
30
+ activeField: (s: import("../../../Form/form.js").FormState<AddonFormValues>) => FieldDecl;
31
+ move: (s: import("../../../Form/form.js").FormState<AddonFormValues>, d: number) => import("../../../Form/form.js").FormState<AddonFormValues>;
32
+ initial(options: AddonFormOptions, prefill?: Partial<AddonFormValues>): import("../../../Form/form.js").FormState<AddonFormValues>;
33
+ apply(s: import("../../../Form/form.js").FormState<AddonFormValues>, options: AddonFormOptions, intent: import("../../../Form/form.js").Intent): import("../../../Form/form.js").FormState<AddonFormValues>;
34
+ toObject: (s: import("../../../Form/form.js").FormState<AddonFormValues>, options: AddonFormOptions) => AddonFormValues;
35
+ };
36
+ export type AddonForm = ReturnType<typeof addonForm>;
37
+ export {};
@@ -0,0 +1,80 @@
1
+ // The form of an addon action with input, over the form kernel: the input
2
+ // subset of the addon schema mapped onto text lines and selects, and the
3
+ // typed payload read back from the strings the form holds.
4
+ import { declareForm } from '../../../Form/form.js';
5
+ /**
6
+ * The rendering table on a terminal: text lines for strings and numbers, a
7
+ * select for an enum, and yes/no for a boolean (there is no toggle widget).
8
+ * A required field without a default is marked `*`.
9
+ */
10
+ export function formFields(input) {
11
+ const required = new Set(input.required ?? []);
12
+ return Object.entries(input.properties).map(([id, field]) => {
13
+ const label = `${field.title ?? id}${required.has(id) && field.default === undefined ? ' *' : ''}`;
14
+ if (field.enum)
15
+ return {
16
+ id,
17
+ label,
18
+ kind: 'select',
19
+ options: field.enum.map((v) => ({ id: String(v), label: String(v) })),
20
+ };
21
+ if (field.type === 'boolean')
22
+ return {
23
+ id,
24
+ label,
25
+ kind: 'select',
26
+ options: [
27
+ { id: 'yes', label: 'yes' },
28
+ { id: 'no', label: 'no' },
29
+ ],
30
+ };
31
+ return { id, label, kind: 'text' };
32
+ });
33
+ }
34
+ /**
35
+ * Typed payload from the form's strings, or the per-field errors. The server
36
+ * validates again; this only spares a round trip for the obvious mistakes.
37
+ */
38
+ export function payloadFrom(input, values) {
39
+ const required = new Set(input.required ?? []);
40
+ const payload = {};
41
+ const errors = {};
42
+ for (const [id, field] of Object.entries(input.properties)) {
43
+ const raw = (values[id] ?? '').trim();
44
+ if (raw === '') {
45
+ if (required.has(id) && field.default === undefined)
46
+ errors[id] = 'is required';
47
+ continue;
48
+ }
49
+ if (field.type === 'boolean')
50
+ payload[id] = raw === 'yes';
51
+ else if (field.type === 'integer') {
52
+ if (!/^-?\d+$/.test(raw))
53
+ errors[id] = 'must be a whole number';
54
+ else
55
+ payload[id] = Number(raw);
56
+ }
57
+ else if (field.type === 'number') {
58
+ const n = Number(raw);
59
+ if (!Number.isFinite(n))
60
+ errors[id] = 'must be a number';
61
+ else
62
+ payload[id] = n;
63
+ }
64
+ else if (field.format === 'uri' && !URL.canParse(raw))
65
+ errors[id] = 'must be a URL';
66
+ else
67
+ payload[id] = raw;
68
+ }
69
+ return Object.keys(errors).length > 0 ? { errors } : { payload };
70
+ }
71
+ /** The kernel form for an input: its fields, then the Send row. */
72
+ export function addonForm(input) {
73
+ const fields = formFields(input).map((f) => f.kind === 'select'
74
+ ? { id: f.id, label: f.label, kind: 'select', from: f.id }
75
+ : { id: f.id, label: f.label, kind: 'text' });
76
+ return declareForm({
77
+ fields: (f) => [...fields, f.action('Send')],
78
+ toObject: (values) => values,
79
+ });
80
+ }
@@ -20,6 +20,8 @@ export interface CreateFormHints {
20
20
  projectId?: string | undefined;
21
21
  /** The launcher's user uuid; hoists everything the machine's user owns (GAIA-460). */
22
22
  ownerUserId?: string | undefined;
23
+ /** Workspace UUID for the Reviewers bag (membership ∩ users). */
24
+ workspaceId?: string | undefined;
23
25
  }
24
26
  /**
25
27
  * Stable-partition the machine's own row to the front: index 0 is what a ring
@@ -21,6 +21,7 @@ import { listTerms } from '../../term/Data/terms.js';
21
21
  import { listUsers } from '../../user/Data/users.js';
22
22
  import { WORKFLOW_OPTIONS, } from './create.js';
23
23
  import { parentOptionSource } from './parent-source.js';
24
+ import { loadReviewerOptions } from './reviewer-options.js';
24
25
  // One back-compat re-export, no implementation: `loadEditFormOptions` lives in
25
26
  // `./edit-form-data.js`, because a module named for the create form owning the EDIT
26
27
  // form's loader is the category error AC-6 is about.
@@ -71,7 +72,7 @@ const LABEL_VOCABULARY = 'gaia_labels';
71
72
  * can lag, but it can never show a row contradicting the text on screen.
72
73
  */
73
74
  export function createFormSources(client, hints = {}) {
74
- const { currentUser, ownConductorMachineId, ownerUserId, projectId } = hints;
75
+ const { currentUser, ownConductorMachineId, ownerUserId, projectId, workspaceId, } = hints;
75
76
  const sources = {
76
77
  assignee: {
77
78
  bag: 'users',
@@ -84,6 +85,12 @@ export function createFormSources(client, hints = {}) {
84
85
  return { options: rows.map(toOption), error: outcome.error };
85
86
  },
86
87
  },
88
+ reviewers: {
89
+ bag: 'reviewers',
90
+ async load() {
91
+ return loadReviewerOptions(client, workspaceId);
92
+ },
93
+ },
87
94
  conductor: {
88
95
  bag: 'conductors',
89
96
  async load() {
@@ -131,6 +138,7 @@ export async function composeCreateFormOptions(sources) {
131
138
  const { bags, errors } = await primeSources(sources);
132
139
  return {
133
140
  users: [],
141
+ reviewers: [],
134
142
  conductors: [],
135
143
  labels: [],
136
144
  workflows: WORKFLOW_OPTIONS,
@@ -12,6 +12,7 @@ export { cycleValue };
12
12
  */
13
13
  export interface CreateOptionErrors {
14
14
  users?: string | undefined;
15
+ reviewers?: string | undefined;
15
16
  conductors?: string | undefined;
16
17
  labels?: string | undefined;
17
18
  parents?: string | undefined;
@@ -38,6 +39,7 @@ export interface ParentOption extends Option {
38
39
  }
39
40
  export interface CreateFormOptions {
40
41
  users: Option[];
42
+ reviewers: Option[];
41
43
  conductors: Option[];
42
44
  labels: Option[];
43
45
  workflows: Option[];
@@ -67,7 +69,7 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
67
69
  */
68
70
  export declare const AGENT_ACTION_ID = "submit";
69
71
  export declare const DIRECT_ACTION_ID = "direct";
70
- export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
72
+ export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
71
73
  /** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
72
74
  export interface CreateValues {
73
75
  /**
@@ -84,6 +86,7 @@ export interface CreateValues {
84
86
  parent: string | null;
85
87
  parentQuery: string;
86
88
  assignee: string | null;
89
+ reviewers: ReadonlySet<string>;
87
90
  conductor: string | null;
88
91
  labels: ReadonlySet<string>;
89
92
  workflow: string | null;
@@ -101,6 +104,7 @@ export interface CreateFormPayload {
101
104
  description: string;
102
105
  workflow: Option | null;
103
106
  assignee: Option | null;
107
+ reviewers: Option[];
104
108
  conductor: Option | null;
105
109
  labels: Option[];
106
110
  /**
@@ -76,6 +76,7 @@ export const CREATE_FORM = declareForm({
76
76
  pageSize: PARENT_PAGE_SIZE,
77
77
  }),
78
78
  f.select('assignee', 'Assignee', { from: 'users' }),
79
+ f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
79
80
  // Clearable (D2): a SET conductor means the ticket is claimed at the next
80
81
  // poll and a qualification run starts; a cleared one means it waits for a
81
82
  // human. The field is clearable precisely so that choice is the operator's
@@ -95,6 +96,7 @@ export const CREATE_FORM = declareForm({
95
96
  description: v.description,
96
97
  workflow: optionFor(o.workflows, v.workflow),
97
98
  assignee: optionFor(o.users, v.assignee),
99
+ reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
98
100
  conductor: optionFor(o.conductors, v.conductor),
99
101
  labels: o.labels.filter((x) => v.labels.has(x.id)),
100
102
  parent: parentFor(o.parents ?? [], v.parent),
@@ -135,6 +137,7 @@ export const serialize = (s, opts) => CREATE_FORM.toObject(s, opts);
135
137
  // signatures predate the kernel and take no options — pass an empty bag.
136
138
  const NO_OPTIONS = {
137
139
  users: [],
140
+ reviewers: [],
138
141
  conductors: [],
139
142
  labels: [],
140
143
  workflows: [],
@@ -7,5 +7,7 @@ export interface EditFormHints {
7
7
  projectId?: string | undefined;
8
8
  /** The launcher's user uuid; hoists everything the machine's user owns (GAIA-460). */
9
9
  ownerUserId?: string | undefined;
10
+ /** Workspace UUID for the Reviewers bag (membership ∩ users). */
11
+ workspaceId?: string | undefined;
10
12
  }
11
13
  export declare function loadEditFormOptions(clients: EditFormClients, hints?: EditFormHints): Promise<EditFormOptions>;
@@ -15,6 +15,7 @@ import { listConductorsPreferringWrite, } from '../../conductor/Data/conductors.
15
15
  import { listTerms } from '../../term/Data/terms.js';
16
16
  import { listUsersPreferringWrite } from '../../user/Data/users.js';
17
17
  import { ownFirst } from './create-data.js';
18
+ import { loadReviewerOptions } from './reviewer-options.js';
18
19
  /** The vocabulary this popup offers as its labels (GAIA-360 D9 / D11). */
19
20
  const LABEL_VOCABULARY = 'gaia_labels';
20
21
  /**
@@ -42,14 +43,26 @@ export async function loadEditFormOptions(clients, hints = {}) {
42
43
  const labelsPromise = clients.read
43
44
  ? listTerms(clients.read, LABEL_VOCABULARY).then((outcome) => outcome.rows.map((t) => ({ id: t.id, label: t.label })), () => [])
44
45
  : Promise.resolve([]);
45
- const [users, conductors, labels] = await Promise.all([
46
+ const reviewerClient = clients.write ?? clients.read;
47
+ const reviewersPromise = reviewerClient
48
+ ? loadReviewerOptions(reviewerClient, hints.workspaceId)
49
+ : Promise.resolve({
50
+ options: [],
51
+ error: 'no client — reviewers cannot be loaded',
52
+ });
53
+ const [users, conductors, labels, reviewers] = await Promise.all([
46
54
  listUsersPreferringWrite(clients),
47
55
  listConductorsPreferringWrite(clients),
48
56
  labelsPromise,
57
+ reviewersPromise,
49
58
  ]);
50
59
  return {
51
60
  users: excludeAnonymous(users).map((u) => ({ id: u.id, label: u.label })),
61
+ reviewers: reviewers.options,
52
62
  conductors: ownFirst(inProject(conductors, hints.projectId).filter((c) => c.active), (c) => hints.ownerUserId !== undefined && c.ownerUserId === hints.ownerUserId).map(withStatusSuffix),
53
63
  labels,
64
+ ...(reviewers.error === undefined
65
+ ? {}
66
+ : { errors: { reviewers: reviewers.error } }),
54
67
  };
55
68
  }
@@ -1,7 +1,7 @@
1
1
  import { type FormState } from '../../../Form/index.js';
2
2
  import type { TicketDetailDocument } from '../../../types.js';
3
3
  import type { FieldDef, Option } from './create.js';
4
- export type EditFieldId = 'assignee' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
4
+ export type EditFieldId = 'assignee' | 'reviewers' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
5
5
  /**
6
6
  * GAIA-313 / GAIA-360 — the ONE lifecycle move this popup offers, as a named
7
7
  * constant.
@@ -35,8 +35,12 @@ export declare const TERMINAL_TICKET_STATES: ReadonlySet<string>;
35
35
  /** The option lists the popup offers (loaded by `loadEditFormOptions`). */
36
36
  export interface EditFormOptions {
37
37
  users: Option[];
38
+ reviewers: Option[];
38
39
  conductors: Option[];
39
40
  labels: Option[];
41
+ errors?: {
42
+ reviewers?: string | undefined;
43
+ } | undefined;
40
44
  /**
41
45
  * Parent candidates from the option loader (project-scoped typeahead). Absent
42
46
  * until the screen primes a source; an empty array means "searched, nothing
@@ -48,7 +52,10 @@ export interface EditFormOptions {
48
52
  * verbatim so submit needs no ambient state, and never transitions it. */
49
53
  interface EditValues {
50
54
  ticketId: string;
55
+ /** Whether `relationships.reviewers` was present on the loaded document. */
56
+ reviewersLoaded: boolean;
51
57
  assignee: string | null;
58
+ reviewers: ReadonlySet<string>;
52
59
  conductor: string | null;
53
60
  /** The human duration as TYPED — seconds only exist at `toObject` (D5). */
54
61
  estimate: string;
@@ -86,6 +93,12 @@ export interface EditFormPayload {
86
93
  id: string;
87
94
  } | null;
88
95
  };
96
+ reviewers?: {
97
+ data: {
98
+ type: string;
99
+ id: string;
100
+ }[];
101
+ };
89
102
  };
90
103
  }
91
104
  /** Cancel ticket writes ONLY the lifecycle attribute (GAIA-360 D1). */
@@ -105,6 +105,7 @@ const timeAttributes = (v) => {
105
105
  const declareEditForm = (savable, cancellable) => declareForm({
106
106
  fields: (f) => [
107
107
  f.select('assignee', 'Assignee', { from: 'users', clearable: true }),
108
+ f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
108
109
  f.select('conductor', 'Conductor', {
109
110
  from: 'conductors',
110
111
  clearable: true,
@@ -132,6 +133,16 @@ const declareEditForm = (savable, cancellable) => declareForm({
132
133
  conductor_id: linkage('gaia_conductor--gaia_conductor', v.conductor),
133
134
  labels: labelsLinkage(v.labels),
134
135
  parent_id: linkage('gaia_ticket--gaia_ticket', v.parent),
136
+ ...(v.reviewersLoaded || v.reviewers.size > 0
137
+ ? {
138
+ reviewers: {
139
+ data: [...v.reviewers].map((id) => ({
140
+ type: 'user--user',
141
+ id,
142
+ })),
143
+ },
144
+ }
145
+ : {}),
135
146
  },
136
147
  }),
137
148
  });
@@ -178,6 +189,7 @@ export const EDIT_FIELDS_CLOSED = EDIT_FORM_CLOSED.fields;
178
189
  */
179
190
  export const EMPTY_EDIT_FORM_OPTIONS = {
180
191
  users: [],
192
+ reviewers: [],
181
193
  conductors: [],
182
194
  labels: [],
183
195
  };
@@ -196,6 +208,22 @@ const labelIds = (document) => {
196
208
  }
197
209
  return data.id ? new Set([data.id]) : new Set();
198
210
  };
211
+ const reviewersFromDocument = (document) => {
212
+ const rel = document.data?.relationships?.reviewers;
213
+ if (rel === undefined)
214
+ return { ids: new Set(), loaded: false };
215
+ const data = rel.data;
216
+ // A to-many relationship without `data` was not read; never treat it as empty.
217
+ if (!data)
218
+ return { ids: new Set(), loaded: false };
219
+ if (Array.isArray(data)) {
220
+ return {
221
+ ids: new Set(data.map((row) => row.id).filter(Boolean)),
222
+ loaded: true,
223
+ };
224
+ }
225
+ return { ids: data.id ? new Set([data.id]) : new Set(), loaded: true };
226
+ };
199
227
  const timeValue = (document, name) => {
200
228
  const value = document.data?.attributes?.[name];
201
229
  // An unset field prefills EMPTY, never `0m`: the popup must not turn "nobody
@@ -227,9 +255,12 @@ const parentQueryFromDocument = (document, parentId) => {
227
255
  * explicit `null` STICKS instead of falling back to the first option. */
228
256
  export function initialEditForm(document) {
229
257
  const parent = relId(document, 'parent_id');
258
+ const reviewers = reviewersFromDocument(document);
230
259
  return EDIT_FORM.initial(EMPTY_EDIT_FORM_OPTIONS, {
231
260
  ticketId: document.data?.id ?? '',
261
+ reviewersLoaded: reviewers.loaded,
232
262
  assignee: relId(document, 'assignee_user_id'),
263
+ reviewers: reviewers.ids,
233
264
  conductor: relId(document, 'conductor_id'),
234
265
  estimate: timeValue(document, 'time_estimate'),
235
266
  spent: timeValue(document, 'time_spent'),
@@ -0,0 +1,10 @@
1
+ import type { Option } from '../../../Form/index.js';
2
+ import type { DropshClient } from '../../../types.js';
3
+ export declare const NO_WORKSPACE_REVIEWERS_ERROR = "no workspace in scope \u2014 reviewers cannot be loaded";
4
+ export interface ReviewerOptionOutcome {
5
+ options: Option[];
6
+ error?: string | undefined;
7
+ }
8
+ /** Selected ids that are not in the bag stay visible/removable. */
9
+ export declare function withSelectedReviewers(bag: Option[], selected: Iterable<string>, labels?: ReadonlyMap<string, string>): Option[];
10
+ export declare function loadReviewerOptions(client: DropshClient, workspaceId: string | undefined): Promise<ReviewerOptionOutcome>;
@@ -0,0 +1,48 @@
1
+ // Form-layer adapter: the Reviewers option bag is membership ∩ users.
2
+ // Data services stay form-ignorant; this module owns the intersection, the
3
+ // Anonymous drop, and the reviewer-specific error. Never falls back to the
4
+ // instance-wide user list.
5
+ import { listAllUsers } from '../../user/Data/users.js';
6
+ import { loadWorkspaceMemberships } from '../../workspace/Data/members.js';
7
+ export const NO_WORKSPACE_REVIEWERS_ERROR = 'no workspace in scope — reviewers cannot be loaded';
8
+ const toOption = (row) => ({
9
+ id: row.id,
10
+ label: row.label,
11
+ });
12
+ /** Selected ids that are not in the bag stay visible/removable. */
13
+ export function withSelectedReviewers(bag, selected, labels = new Map()) {
14
+ const known = new Set(bag.map((option) => option.id));
15
+ const extra = [];
16
+ for (const id of selected) {
17
+ if (known.has(id))
18
+ continue;
19
+ extra.push({ id, label: labels.get(id) ?? id });
20
+ }
21
+ return extra.length === 0 ? bag : [...bag, ...extra];
22
+ }
23
+ export async function loadReviewerOptions(client, workspaceId) {
24
+ if (!workspaceId) {
25
+ return { options: [], error: NO_WORKSPACE_REVIEWERS_ERROR };
26
+ }
27
+ try {
28
+ const [memberships, users] = await Promise.all([
29
+ loadWorkspaceMemberships(client, workspaceId),
30
+ listAllUsers(client),
31
+ ]);
32
+ if (users.error) {
33
+ return { options: [], error: users.error };
34
+ }
35
+ const memberIds = new Set(memberships.memberIds);
36
+ return {
37
+ options: users.rows
38
+ .filter((user) => memberIds.has(user.id) && user.uid !== 0)
39
+ .map(toOption),
40
+ };
41
+ }
42
+ catch (caught) {
43
+ return {
44
+ options: [],
45
+ error: caught instanceof Error ? caught.message : String(caught),
46
+ };
47
+ }
48
+ }
@@ -57,8 +57,8 @@ export declare class ConductorTicketsScreen extends ListScreen<TicketRow, void>
57
57
  }, {
58
58
  readonly key: "run";
59
59
  readonly label: "RUN";
60
- readonly width: 8;
61
- readonly fields: readonly ["derived_status"];
60
+ readonly width: 12;
61
+ readonly fields: readonly ["derived_status", "stall_reason"];
62
62
  readonly get: (r: TicketRow) => string;
63
63
  }, {
64
64
  readonly key: "conductor";
@@ -90,10 +90,14 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
90
90
  ...(context.project === undefined
91
91
  ? {}
92
92
  : { projectId: context.project.id }),
93
+ ...(context.services.workspaceId === undefined
94
+ ? {}
95
+ : { workspaceId: context.services.workspaceId }),
93
96
  });
94
97
  this.sources = sources;
95
98
  return composeCreateFormOptions(sources).catch((caught) => ({
96
99
  users: [],
100
+ reviewers: [],
97
101
  conductors: [],
98
102
  labels: [],
99
103
  workflows: [],
@@ -13,6 +13,7 @@ import { CANCEL_TICKET_ACTION_ID, cancelEditPayload, EDIT_FORM_CLOSED, EMPTY_EDI
13
13
  import { loadEditFormOptions } from '../Form/edit-data.js';
14
14
  import { editParentOptionSource } from '../Form/edit-parent-source.js';
15
15
  import { keyToIntent } from '../Form/keys.js';
16
+ import { withSelectedReviewers } from '../Form/reviewer-options.js';
16
17
  export const editAssignmentInputContext = defineContext('edit-assignment-input', 'input');
17
18
  const relationshipId = (document, name) => {
18
19
  const data = document.data?.relationships?.[name]?.data;
@@ -20,6 +21,27 @@ const relationshipId = (document, name) => {
20
21
  return undefined;
21
22
  return Array.isArray(data) ? data[0]?.id : data.id;
22
23
  };
24
+ const reviewerIds = (document) => {
25
+ const data = document.data?.relationships?.reviewers?.data;
26
+ if (!data)
27
+ return [];
28
+ return (Array.isArray(data) ? data : [data])
29
+ .map((row) => row.id)
30
+ .filter((id) => Boolean(id));
31
+ };
32
+ const includedUserLabels = (document) => {
33
+ const labels = new Map();
34
+ for (const row of document.included ?? []) {
35
+ if (row.type !== 'user--user' || !row.id)
36
+ continue;
37
+ const attrs = row.attributes ?? {};
38
+ const label = (typeof attrs.display_name === 'string' && attrs.display_name) ||
39
+ (typeof attrs.name === 'string' && attrs.name) ||
40
+ row.id;
41
+ labels.set(row.id, label);
42
+ }
43
+ return labels;
44
+ };
23
45
  export class EditAssignmentFormScreen extends AsyncModalScreen {
24
46
  static result(ticketId, assigneeId) {
25
47
  return { type: 'ticket-assignment-updated', ticketId, assigneeId };
@@ -82,7 +104,7 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
82
104
  // once per screen lifetime through `OwnerIdentity` rather than threaded
83
105
  // straight through. Fails open: `unavailable` omits the hint entirely.
84
106
  const ownerOutcome = await this.ownerIdentity.resolve(context.services.client, context.services.currentUser);
85
- const options = await loadEditFormOptions({
107
+ const loaded = await loadEditFormOptions({
86
108
  read: context.services.client,
87
109
  ...(context.services.writeClient === undefined
88
110
  ? {}
@@ -92,7 +114,14 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
92
114
  ...(ownerOutcome.kind === 'resolved'
93
115
  ? { ownerUserId: ownerOutcome.id }
94
116
  : {}),
117
+ ...(context.services.workspaceId === undefined
118
+ ? {}
119
+ : { workspaceId: context.services.workspaceId }),
95
120
  }).catch(() => EMPTY_EDIT_FORM_OPTIONS);
121
+ const options = {
122
+ ...loaded,
123
+ reviewers: withSelectedReviewers(loaded.reviewers, reviewerIds(document), includedUserLabels(document)),
124
+ };
96
125
  // Parent typeahead: project-scoped source with self-exclusion (D5/D11).
97
126
  // No project ⇒ no source, so the bag stays absent and the field shows
98
127
  // its absentNote. Built as one object: OptionSources is Readonly and
@@ -0,0 +1,94 @@
1
+ import type { VNode } from '@opentui/core';
2
+ import { type ActionItem } from '../../../Component/footer-actions/index.js';
3
+ import { AsyncModalScreen } from '../../../Screen/async-modal-screen.js';
4
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
5
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
6
+ import type { ModalChrome } from '../../../Screen/modal-screen.js';
7
+ import type { ScreenAction, ScreenContext } from '../../../Screen/screen.js';
8
+ import type { TicketDetailDocument } from '../../../types.js';
9
+ import { type TicketAddon, type TicketAddons } from '../Data/addons.js';
10
+ interface AddonsData {
11
+ ticket: TicketDetailDocument;
12
+ model: TicketAddons;
13
+ }
14
+ /** What the message form needs; passed as navigation state. */
15
+ export interface AddonFormState {
16
+ ticketUuid: string;
17
+ addon: string;
18
+ key: string;
19
+ label: string;
20
+ input: NonNullable<TicketAddon['messages'][number]['input']>;
21
+ }
22
+ export declare class TicketAddonsScreen extends AsyncScreen<AddonsData> {
23
+ static readonly route: {
24
+ id: string;
25
+ path: string;
26
+ parent: string;
27
+ fallback: {
28
+ routeId: string;
29
+ };
30
+ context: (params: Readonly<Record<string, string>>, state: unknown, base: import("../../../Routing/context.js").ContextBag) => import("../../../Routing/context.js").ContextBag;
31
+ };
32
+ readonly id: string;
33
+ readonly frameId = "ticket";
34
+ private readonly cursor;
35
+ private selected;
36
+ private sending;
37
+ private timer;
38
+ protected load(context: ScreenContext): Promise<AddonsData>;
39
+ protected didLoad(data: AddonsData, context: ScreenContext): void;
40
+ reload(context: ScreenContext): void;
41
+ leave(): void;
42
+ reset(): void;
43
+ /** Re-read every 3 s while the shown addon has an open message. */
44
+ private schedulePoll;
45
+ private stopPoll;
46
+ private addon;
47
+ private entries;
48
+ private clampCursor;
49
+ protected renderLoading(): string[];
50
+ private lines;
51
+ protected renderReady(data: AddonsData, context: ScreenContext): VNode;
52
+ renderNonInteractive(context: ScreenContext): string[];
53
+ protected chrome(context: ScreenContext): FullScreenChrome;
54
+ protected actions(): readonly ActionItem[];
55
+ protected notice(): string | undefined;
56
+ select(index: number, context: ScreenContext): boolean;
57
+ activate(index: number, context: ScreenContext): boolean;
58
+ /** Runs an entry's message: sends it, or opens its form. */
59
+ private run;
60
+ handleInput(input: string, context: ScreenContext): boolean;
61
+ commandForInput(input: string): string | undefined;
62
+ actionForInput(input: string, context: ScreenContext): ScreenAction | undefined;
63
+ receiveResult(result: unknown, context: ScreenContext): void;
64
+ }
65
+ type FormOptions = Record<string, {
66
+ id: string;
67
+ label: string;
68
+ }[]>;
69
+ /** The form of an action with input, built from its input subset. */
70
+ export declare class AddonActionFormScreen extends AsyncModalScreen<FormOptions> {
71
+ static readonly route: {
72
+ id: string;
73
+ path: string;
74
+ parent: string;
75
+ presentation: "modal";
76
+ };
77
+ readonly id: string;
78
+ private target;
79
+ private form;
80
+ private values;
81
+ private errors;
82
+ private sending;
83
+ protected load(context: ScreenContext): Promise<FormOptions>;
84
+ protected didLoad(options: FormOptions): void;
85
+ protected renderLoading(): string[];
86
+ protected renderReady(options: FormOptions): VNode;
87
+ protected modalChrome(): ModalChrome;
88
+ protected actions(): readonly ActionItem[];
89
+ handleInput(input: string, context: ScreenContext): boolean;
90
+ private submit;
91
+ protected notice(): string | undefined;
92
+ reset(): void;
93
+ }
94
+ export {};