@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
@@ -1,13 +1,18 @@
1
1
  import { actionItem, } from '../../../Component/footer-actions/index.js';
2
2
  import { copyThrough } from '../../../lib/clipboard.js';
3
+ import { serverMessage } from '../../../lib/jsonapi-error.js';
3
4
  import { BORDER, textNode } from '../../../lib/vnode.js';
4
5
  import { AsyncScreen, } from '../../../Screen/async-screen.js';
5
- import { TicketContent, } from '../Component/ticket-content/index.js';
6
+ import { runRowAction } from '../../../Screen/row-action.js';
7
+ import { ADDONS_KEY, hasAddonsMessages, TicketContent, } from '../Component/ticket-content/index.js';
6
8
  import { initialExpandedKeys, resolveSelectedKey, retainExpandedKeys, ticketContentModel, ticketContentRowId, } from '../Component/ticket-content/model.js';
7
9
  import { handleTicketCopy, ticketCopyActions, ticketEditAction, ticketHeaderTree, ticketTitleOf, } from '../Component/ticket-detail/index.js';
8
10
  import { ticketTeaserLines, ticketTeaserTree, } from '../Component/ticket-teaser/index.js';
11
+ import { readTicketAddons } from '../Data/addons.js';
12
+ import { loadTicketDetail } from '../Data/detail.js';
9
13
  import { ticketDetailInputContext } from '../Data/input.js';
10
14
  import { loadTicketView, readTicket, refreshTicket, saveTicket, ticketLoaded, watchTicket, } from '../Data/store.js';
15
+ import { updateTicketFrozen } from '../Data/write.js';
11
16
  /** Overview width when the context carries none — a pipe has no terminal. */
12
17
  const DEFAULT_COLUMNS = 80;
13
18
  /**
@@ -20,12 +25,16 @@ export class TicketContentScreen extends AsyncScreen {
20
25
  initializedKey;
21
26
  labels;
22
27
  status = '';
28
+ freezeBusy = new Set();
23
29
  releaseStore;
24
30
  expanded = new Set();
25
31
  viewMode = 'full';
26
32
  /** Visit-local: the sidebar pane and Planning document tab last chosen. */
27
33
  sidebarTab;
28
34
  documentKey;
35
+ /** Overview only (GAIA-661); a stale load after navigating away is dropped. */
36
+ addonsPreview;
37
+ addonsPreviewGeneration = 0;
29
38
  modelMemo;
30
39
  compact() {
31
40
  return this.viewMode !== 'full';
@@ -69,6 +78,10 @@ export class TicketContentScreen extends AsyncScreen {
69
78
  ...(this.documentKey === undefined
70
79
  ? {}
71
80
  : { documentKey: this.documentKey }),
81
+ ...(this.addonsPreview === undefined
82
+ ? {}
83
+ : { addonsPreview: this.addonsPreview }),
84
+ onOpenAddons: () => this.openAddons(context),
72
85
  onSidebarTab: (tab) => live(() => {
73
86
  this.sidebarTab = tab;
74
87
  }),
@@ -129,8 +142,31 @@ export class TicketContentScreen extends AsyncScreen {
129
142
  this.replaceReady(document);
130
143
  context.requestDraw();
131
144
  });
145
+ this.loadAddonsPreview(context);
132
146
  context.requestDraw();
133
147
  }
148
+ /** The inline card's data (GAIA-661); Overview only, loaded once per visit. */
149
+ loadAddonsPreview(context) {
150
+ this.addonsPreview = undefined;
151
+ if (this.tab !== 'overview')
152
+ return;
153
+ const client = context.services?.client;
154
+ const ticketId = context.params?.ticketId;
155
+ if (!client || !ticketId)
156
+ return;
157
+ const generation = ++this.addonsPreviewGeneration;
158
+ void readTicketAddons(client, ticketId)
159
+ .then((model) => {
160
+ if (generation !== this.addonsPreviewGeneration || !context.isActive())
161
+ return;
162
+ this.addonsPreview = model;
163
+ context.requestDraw();
164
+ })
165
+ // Best-effort preview (matches route.ts's optional-fetch convention): a
166
+ // failed fetch just leaves the card absent rather than crashing the
167
+ // Overview screen or surfacing an unhandled rejection.
168
+ .catch(() => undefined);
169
+ }
134
170
  leave() {
135
171
  this.releaseStore?.();
136
172
  this.releaseStore = undefined;
@@ -157,8 +193,31 @@ export class TicketContentScreen extends AsyncScreen {
157
193
  setLabels(labels) {
158
194
  this.labels = labels;
159
195
  }
160
- select(index, context) {
196
+ /**
197
+ * The model's selectable keys, plus the inline addons card's row (GAIA-661
198
+ * D1) when the Overview preview has a message worth reaching: a Tab/arrow
199
+ * visit stops there and Enter opens the full send/poll screen, the same
200
+ * keyboard reach the Details/Stats/Related sidebar card already has via its
201
+ * own (Technical details) row.
202
+ */
203
+ selectableKeys(context) {
161
204
  const keys = this.syncModel(context).selectableKeys;
205
+ if (this.tab === 'overview' &&
206
+ this.addonsPreview &&
207
+ hasAddonsMessages(this.addonsPreview)) {
208
+ return [...keys, ADDONS_KEY];
209
+ }
210
+ return keys;
211
+ }
212
+ openAddons(context) {
213
+ if (!context.isActive())
214
+ return;
215
+ context.navigate?.('ticket.addons', {
216
+ ticketId: context.params?.ticketId ?? '',
217
+ });
218
+ }
219
+ select(index, context) {
220
+ const keys = this.selectableKeys(context);
162
221
  if (keys.length === 0)
163
222
  return false;
164
223
  const next = Math.max(0, Math.min(index, keys.length - 1));
@@ -172,10 +231,16 @@ export class TicketContentScreen extends AsyncScreen {
172
231
  activate(index, context) {
173
232
  if (!this.document)
174
233
  return false;
175
- const keys = this.syncModel(context).selectableKeys;
234
+ const keys = this.selectableKeys(context);
176
235
  const key = keys[index];
177
236
  if (key === undefined)
178
237
  return false;
238
+ if (key === ADDONS_KEY) {
239
+ this.selectedKey = key;
240
+ context.requestDraw();
241
+ this.openAddons(context);
242
+ return true;
243
+ }
179
244
  const next = new Set(this.expanded);
180
245
  if (next.has(key))
181
246
  next.delete(key);
@@ -186,18 +251,61 @@ export class TicketContentScreen extends AsyncScreen {
186
251
  context.requestDraw();
187
252
  return true;
188
253
  }
254
+ isFrozen() {
255
+ return this.document?.data?.attributes?.frozen === true;
256
+ }
257
+ /**
258
+ * PATCH `frozen` through the write client, then re-read on the session
259
+ * client so the ladder and document agree with the server.
260
+ */
261
+ toggleFrozen(context) {
262
+ const id = this.document?.data?.id ?? context.params?.ticketId;
263
+ if (!id || !context.services)
264
+ return true;
265
+ const next = !this.isFrozen();
266
+ const guard = this.captureLifecycle(context);
267
+ return runRowAction({
268
+ rowId: id,
269
+ busy: this.freezeBusy,
270
+ context,
271
+ perform: async () => {
272
+ try {
273
+ await updateTicketFrozen(context.services?.writeClient ?? context.services?.client, id, next);
274
+ }
275
+ catch (caught) {
276
+ throw new Error(serverMessage(caught));
277
+ }
278
+ return { message: next ? 'frozen' : 'no longer frozen' };
279
+ },
280
+ refresh: async (ctx) => {
281
+ refreshTicket(ctx);
282
+ if (!ctx.services)
283
+ return;
284
+ const document = await loadTicketDetail(ctx.services.client, id, this.includes);
285
+ this.replaceReadyIfCurrent(guard, saveTicket(ctx, document), ctx);
286
+ },
287
+ setStatus: (status) => {
288
+ this.status = status;
289
+ },
290
+ captureLifecycle: () => guard,
291
+ lifecycleIsCurrent: (current, ctx) => this.lifecycleIsCurrent(current, ctx),
292
+ failPrefix: 'freeze failed',
293
+ });
294
+ }
189
295
  handleInput(input, context) {
190
296
  if (super.handleInput(input, context))
191
297
  return true;
192
298
  if (this.state !== 'ready' || this.viewMode !== 'full')
193
299
  return false;
194
- const keys = this.syncModel(context).selectableKeys;
300
+ const keys = this.selectableKeys(context);
195
301
  const selectedIndex = this.selectedIndexOf(keys);
196
302
  if (input === 'y' || input === 'Y') {
197
303
  this.status = handleTicketCopy(input, context, this.document) ?? '';
198
304
  context.requestDraw();
199
305
  return true;
200
306
  }
307
+ if (input === 'f')
308
+ return this.toggleFrozen(context);
201
309
  if ((input === 'enter' || input === ' ') && keys.length > 0)
202
310
  return this.activate(selectedIndex, context);
203
311
  if (keys.length > 1 && (input === 'j' || input === 'down'))
@@ -258,7 +366,7 @@ export class TicketContentScreen extends AsyncScreen {
258
366
  }),
259
367
  ], this.labels),
260
368
  };
261
- const keys = this.syncModel(context).selectableKeys;
369
+ const keys = this.selectableKeys(context);
262
370
  const selectedIndex = this.selectedIndexOf(keys);
263
371
  return {
264
372
  selectable: keys.length,
@@ -272,13 +380,16 @@ export class TicketContentScreen extends AsyncScreen {
272
380
  actions(context) {
273
381
  if (!this.document || this.viewMode !== 'full')
274
382
  return [];
275
- const selectable = this.syncModel(context).selectableKeys.length;
383
+ const selectable = this.selectableKeys(context).length;
276
384
  return [
277
385
  actionItem('detail-tab', 'Tab', 'section'),
278
386
  actionItem('detail-edit', 'e', 'edit'),
279
- ...ticketCopyActions(),
387
+ // Freeze and paging before copy: at 80 columns the footer clips the
388
+ // tail, and `Y:Copy link` is the hint that yields.
389
+ actionItem('detail-freeze', 'f', this.isFrozen() ? 'unfreeze' : 'freeze'),
280
390
  actionItem('detail-arrows', '↑↓', selectable > 1 ? 'select' : 'scroll'),
281
391
  actionItem('detail-page', 'PgUp/PgDn', 'page'),
392
+ ...ticketCopyActions(),
282
393
  ...(selectable > 0
283
394
  ? [actionItem('detail-enter', 'Enter/Space', 'expand')]
284
395
  : []),
@@ -294,10 +405,13 @@ export class TicketContentScreen extends AsyncScreen {
294
405
  this.modelMemo = undefined;
295
406
  this.sidebarTab = undefined;
296
407
  this.documentKey = undefined;
408
+ this.addonsPreview = undefined;
409
+ this.addonsPreviewGeneration += 1;
297
410
  this.selectedKey = undefined;
298
411
  this.initializedKey = undefined;
299
412
  this.labels = undefined;
300
413
  this.status = '';
414
+ this.freezeBusy.clear();
301
415
  this.expanded = new Set();
302
416
  this.viewMode = 'full';
303
417
  }
@@ -341,7 +455,7 @@ export class TicketContentScreen extends AsyncScreen {
341
455
  memo.labels === this.labels &&
342
456
  memo.now === now)
343
457
  return memo.model;
344
- const model = ticketContentModel(document, this.tab, this.labels, now);
458
+ const model = ticketContentModel(document, this.tab, this.labels, now, context.services?.baseUrl);
345
459
  this.modelMemo = { document, labels: this.labels, now, model };
346
460
  return model;
347
461
  }
@@ -17,6 +17,8 @@ const INCLUDES = [
17
17
  'owner_user_id',
18
18
  'sub_tickets',
19
19
  'referenced_tickets',
20
+ 'reviewers',
21
+ 'pending_reviews',
20
22
  ];
21
23
  /** Public Overview route: the ticket's overview view and its interaction state. */
22
24
  export class TicketDetailScreen extends TicketContentScreen {
@@ -41,8 +43,8 @@ export class TicketDetailScreen extends TicketContentScreen {
41
43
  fullNonInteractive(document, context) {
42
44
  const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
43
45
  return flattenSections([
44
- ...detailSections(document, this.labels, nowSeconds).filter((section) => section.heading === 'State' || section.heading === 'Labels'),
45
- ...sectionsForTab(document, 0, this.labels, nowSeconds),
46
+ ...detailSections(document, this.labels, nowSeconds, context.services?.baseUrl).filter((section) => section.heading === 'State' || section.heading === 'Labels'),
47
+ ...sectionsForTab(document, 0, this.labels, nowSeconds, context.services?.baseUrl),
46
48
  ]).lines;
47
49
  }
48
50
  }
@@ -41,8 +41,8 @@ export declare class TicketsScreen extends ListScreen<TicketRow, void> {
41
41
  }, {
42
42
  readonly key: "run";
43
43
  readonly label: "RUN";
44
- readonly width: 8;
45
- readonly fields: readonly ["derived_status"];
44
+ readonly width: 12;
45
+ readonly fields: readonly ["derived_status", "stall_reason"];
46
46
  readonly get: (r: TicketRow) => string;
47
47
  }, {
48
48
  readonly key: "parent";
@@ -42,8 +42,8 @@ export declare abstract class MemberTicketsScreen extends ListScreen<TicketRow,
42
42
  }, {
43
43
  readonly key: "run";
44
44
  readonly label: "RUN";
45
- readonly width: 8;
46
- readonly fields: readonly ["derived_status"];
45
+ readonly width: 12;
46
+ readonly fields: readonly ["derived_status", "stall_reason"];
47
47
  readonly get: (r: TicketRow) => string;
48
48
  }, {
49
49
  readonly key: "parent";
@@ -21,6 +21,7 @@ import { MyWorkScreen } from '../Entity/ticket/Screen/my-work.js';
21
21
  import { MyWorkParentFilterScreen } from '../Entity/ticket/Screen/my-work-parent-filter-form.js';
22
22
  import { ParentFilterScreen } from '../Entity/ticket/Screen/parent-filter-form.js';
23
23
  import { ProjectFilterScreen } from '../Entity/ticket/Screen/project-filter-form.js';
24
+ import { AddonActionFormScreen, TicketAddonsScreen, } from '../Entity/ticket/Screen/ticket-addons.js';
24
25
  import { TicketAgentsScreen } from '../Entity/ticket/Screen/ticket-agents.js';
25
26
  import { TicketCommentsScreen } from '../Entity/ticket/Screen/ticket-comments.js';
26
27
  import { TicketDetailScreen } from '../Entity/ticket/Screen/ticket-detail.js';
@@ -102,6 +103,8 @@ const BUILT_IN = [
102
103
  TicketPlanningScreen,
103
104
  TicketCommentsScreen,
104
105
  TicketRunsScreen,
106
+ TicketAddonsScreen,
107
+ AddonActionFormScreen,
105
108
  RunDetailScreen,
106
109
  EditRunFormScreen,
107
110
  ];
@@ -11,11 +11,15 @@ const multiselectWidget = {
11
11
  inlineValue: false,
12
12
  render(ctx) {
13
13
  const rows = [`${ctx.field.label}:`];
14
+ const list = ctx.list() ?? [];
14
15
  if (ctx.error !== undefined) {
15
- return [...rows, ...note(ctx.error, BODY_INDENT, ctx.width)];
16
+ rows.push(...note(ctx.error, BODY_INDENT, ctx.width));
17
+ // A failed roster can still hold prefilled selections the toggle keys
18
+ // act on and Save writes; they stay visible so none changes unseen.
19
+ if (list.length === 0)
20
+ return rows;
16
21
  }
17
- const list = ctx.list() ?? [];
18
- if (list.length === 0) {
22
+ else if (list.length === 0) {
19
23
  // The honest empty case: the query SUCCEEDED and there is nothing to
20
24
  // offer. `trusted` is false when a form-level failure means no read
21
25
  // succeeded at all, and then the popup knows nothing about this bag and
@@ -48,6 +48,11 @@ export interface DropshClient {
48
48
  * declaring it costs no HTTP plumbing and no new dependency.
49
49
  */
50
50
  create(type: string, body: ResourceWriteBody): Promise<unknown>;
51
+ /**
52
+ * POST a raw document to a path — dropsh's `post`, for the custom resources
53
+ * that are not an entity create (the addon inbox send).
54
+ */
55
+ post(path: string, body: unknown): Promise<unknown>;
51
56
  }
52
57
  /** A JSON:API relationship linkage. */
53
58
  export interface JsonApiRelationship {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-gaia-ui",
3
- "version": "0.17.2",
3
+ "version": "0.19.0",
4
4
  "description": "GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "zustand": "^5.0.15"
30
30
  },
31
31
  "peerDependencies": {
32
- "@gaia-ai/core": "^0.17.2"
32
+ "@gaia-ai/core": "^0.19.0",
33
+ "@gaia-ai/message": "^0.19.0"
33
34
  }
34
35
  }