@gaia-ai/addon-gaia-ui 0.17.2 → 0.18.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.
@@ -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));
@@ -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
  /**
@@ -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. */
@@ -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";
@@ -18,6 +18,7 @@ export declare abstract class TicketContentScreen extends AsyncScreen<TicketDeta
18
18
  private initializedKey;
19
19
  protected labels: AssignmentLabels | undefined;
20
20
  private status;
21
+ private readonly freezeBusy;
21
22
  private releaseStore;
22
23
  private expanded;
23
24
  protected viewMode: UiViewMode;
@@ -47,6 +48,12 @@ export declare abstract class TicketContentScreen extends AsyncScreen<TicketDeta
47
48
  setLabels(labels: AssignmentLabels | undefined): void;
48
49
  select(index: number, context: ScreenContext): boolean;
49
50
  activate(index: number, context: ScreenContext): boolean;
51
+ private isFrozen;
52
+ /**
53
+ * PATCH `frozen` through the write client, then re-read on the session
54
+ * client so the ladder and document agree with the server.
55
+ */
56
+ private toggleFrozen;
50
57
  handleInput(input: string, context: ScreenContext): boolean;
51
58
  commandForInput(input: string): string | undefined;
52
59
  actionForInput(input: string, context: ScreenContext): ScreenAction | undefined;
@@ -1,13 +1,17 @@
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';
6
+ import { runRowAction } from '../../../Screen/row-action.js';
5
7
  import { 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 { loadTicketDetail } from '../Data/detail.js';
9
12
  import { ticketDetailInputContext } from '../Data/input.js';
10
13
  import { loadTicketView, readTicket, refreshTicket, saveTicket, ticketLoaded, watchTicket, } from '../Data/store.js';
14
+ import { updateTicketFrozen } from '../Data/write.js';
11
15
  /** Overview width when the context carries none — a pipe has no terminal. */
12
16
  const DEFAULT_COLUMNS = 80;
13
17
  /**
@@ -20,6 +24,7 @@ export class TicketContentScreen extends AsyncScreen {
20
24
  initializedKey;
21
25
  labels;
22
26
  status = '';
27
+ freezeBusy = new Set();
23
28
  releaseStore;
24
29
  expanded = new Set();
25
30
  viewMode = 'full';
@@ -186,6 +191,47 @@ export class TicketContentScreen extends AsyncScreen {
186
191
  context.requestDraw();
187
192
  return true;
188
193
  }
194
+ isFrozen() {
195
+ return this.document?.data?.attributes?.frozen === true;
196
+ }
197
+ /**
198
+ * PATCH `frozen` through the write client, then re-read on the session
199
+ * client so the ladder and document agree with the server.
200
+ */
201
+ toggleFrozen(context) {
202
+ const id = this.document?.data?.id ?? context.params?.ticketId;
203
+ if (!id || !context.services)
204
+ return true;
205
+ const next = !this.isFrozen();
206
+ const guard = this.captureLifecycle(context);
207
+ return runRowAction({
208
+ rowId: id,
209
+ busy: this.freezeBusy,
210
+ context,
211
+ perform: async () => {
212
+ try {
213
+ await updateTicketFrozen(context.services?.writeClient ?? context.services?.client, id, next);
214
+ }
215
+ catch (caught) {
216
+ throw new Error(serverMessage(caught));
217
+ }
218
+ return { message: next ? 'frozen' : 'no longer frozen' };
219
+ },
220
+ refresh: async (ctx) => {
221
+ refreshTicket(ctx);
222
+ if (!ctx.services)
223
+ return;
224
+ const document = await loadTicketDetail(ctx.services.client, id, this.includes);
225
+ this.replaceReadyIfCurrent(guard, saveTicket(ctx, document), ctx);
226
+ },
227
+ setStatus: (status) => {
228
+ this.status = status;
229
+ },
230
+ captureLifecycle: () => guard,
231
+ lifecycleIsCurrent: (current, ctx) => this.lifecycleIsCurrent(current, ctx),
232
+ failPrefix: 'freeze failed',
233
+ });
234
+ }
189
235
  handleInput(input, context) {
190
236
  if (super.handleInput(input, context))
191
237
  return true;
@@ -198,6 +244,8 @@ export class TicketContentScreen extends AsyncScreen {
198
244
  context.requestDraw();
199
245
  return true;
200
246
  }
247
+ if (input === 'f')
248
+ return this.toggleFrozen(context);
201
249
  if ((input === 'enter' || input === ' ') && keys.length > 0)
202
250
  return this.activate(selectedIndex, context);
203
251
  if (keys.length > 1 && (input === 'j' || input === 'down'))
@@ -276,9 +324,12 @@ export class TicketContentScreen extends AsyncScreen {
276
324
  return [
277
325
  actionItem('detail-tab', 'Tab', 'section'),
278
326
  actionItem('detail-edit', 'e', 'edit'),
279
- ...ticketCopyActions(),
327
+ // Freeze and paging before copy: at 80 columns the footer clips the
328
+ // tail, and `Y:Copy link` is the hint that yields.
329
+ actionItem('detail-freeze', 'f', this.isFrozen() ? 'unfreeze' : 'freeze'),
280
330
  actionItem('detail-arrows', '↑↓', selectable > 1 ? 'select' : 'scroll'),
281
331
  actionItem('detail-page', 'PgUp/PgDn', 'page'),
332
+ ...ticketCopyActions(),
282
333
  ...(selectable > 0
283
334
  ? [actionItem('detail-enter', 'Enter/Space', 'expand')]
284
335
  : []),
@@ -298,6 +349,7 @@ export class TicketContentScreen extends AsyncScreen {
298
349
  this.initializedKey = undefined;
299
350
  this.labels = undefined;
300
351
  this.status = '';
352
+ this.freezeBusy.clear();
301
353
  this.expanded = new Set();
302
354
  this.viewMode = 'full';
303
355
  }
@@ -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";
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.18.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,6 @@
29
29
  "zustand": "^5.0.15"
30
30
  },
31
31
  "peerDependencies": {
32
- "@gaia-ai/core": "^0.17.2"
32
+ "@gaia-ai/core": "^0.18.0"
33
33
  }
34
34
  }