@gaia-ai/addon-gaia-ui 0.16.1 → 0.16.2

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 (31) hide show
  1. package/dist/src/Entity/team/Data/workload.d.ts +3 -1
  2. package/dist/src/Entity/team/Data/workload.js +6 -4
  3. package/dist/src/Entity/team/Screen/team-work.d.ts +12 -1
  4. package/dist/src/Entity/team/Screen/team-work.js +47 -2
  5. package/dist/src/Entity/ticket/Data/parents.js +1 -1
  6. package/dist/src/Entity/ticket/Data/query.d.ts +2 -2
  7. package/dist/src/Entity/ticket/Data/query.js +13 -9
  8. package/dist/src/Entity/ticket/Data/tickets.d.ts +1 -1
  9. package/dist/src/Entity/ticket/Data/tickets.js +1 -1
  10. package/dist/src/Entity/ticket/Form/edit.d.ts +13 -0
  11. package/dist/src/Entity/ticket/Form/edit.js +17 -7
  12. package/dist/src/Entity/ticket/Screen/conductor-tickets.d.ts +7 -1
  13. package/dist/src/Entity/ticket/Screen/conductor-tickets.js +30 -6
  14. package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +3 -1
  15. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +21 -6
  16. package/dist/src/Entity/ticket/Screen/my-work.d.ts +8 -1
  17. package/dist/src/Entity/ticket/Screen/my-work.js +37 -4
  18. package/dist/src/Entity/ticket/Screen/ticket-filter-bar.js +2 -9
  19. package/dist/src/Entity/ticket/Screen/ticket-search.d.ts +44 -0
  20. package/dist/src/Entity/ticket/Screen/ticket-search.js +97 -0
  21. package/dist/src/Entity/ticket/Screen/tickets.d.ts +9 -7
  22. package/dist/src/Entity/ticket/Screen/tickets.js +25 -85
  23. package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +7 -0
  24. package/dist/src/Entity/ticket/Screen/user-tickets.js +30 -2
  25. package/dist/src/Screen/async-screen.d.ts +7 -0
  26. package/dist/src/Screen/async-screen.js +25 -4
  27. package/dist/src/Screen/list-screen.d.ts +11 -2
  28. package/dist/src/Screen/list-screen.js +20 -2
  29. package/dist/src/route.js +37 -0
  30. package/dist/src/types.d.ts +5 -1
  31. package/package.json +2 -2
@@ -20,4 +20,6 @@ export interface TeamWorkloadResult {
20
20
  waiting: number;
21
21
  };
22
22
  }
23
- export declare function loadTeamWorkload(client: DropshClient, teamId: string): Promise<TeamWorkloadResult>;
23
+ export declare function loadTeamWorkload(client: DropshClient, teamId: string,
24
+ /** The `/` line: the workload counts only the tickets it finds. */
25
+ query?: string): Promise<TeamWorkloadResult>;
@@ -4,11 +4,11 @@ import { classifyWorkload } from '../../ticket/Data/workload.js';
4
4
  import { listTeamMemberIds } from './members.js';
5
5
  import { listLinkedProjectIds } from './projects.js';
6
6
  import { resolveTeam } from './team.js';
7
- async function listProjectTickets(client, projectId) {
7
+ async function listProjectTickets(client, projectId, query) {
8
8
  const rows = [];
9
9
  let pageIndex = 0;
10
10
  while (true) {
11
- const page = await loadFilteredTickets(client, projectId, DEFAULT_TICKET_CRITERIA, pageIndex);
11
+ const page = await loadFilteredTickets(client, projectId, { ...DEFAULT_TICKET_CRITERIA, query }, pageIndex);
12
12
  if (page.withheld) {
13
13
  throw new Error('the control plane withheld project ticket rows');
14
14
  }
@@ -18,7 +18,9 @@ async function listProjectTickets(client, projectId) {
18
18
  pageIndex++;
19
19
  }
20
20
  }
21
- export async function loadTeamWorkload(client, teamId) {
21
+ export async function loadTeamWorkload(client, teamId,
22
+ /** The `/` line: the workload counts only the tickets it finds. */
23
+ query = '') {
22
24
  const [team, membership, projectIds] = await Promise.all([
23
25
  resolveTeam(client, teamId),
24
26
  listTeamMemberIds(client, teamId),
@@ -27,7 +29,7 @@ export async function loadTeamWorkload(client, teamId) {
27
29
  if (membership.ids === null) {
28
30
  throw new Error(membership.error ?? 'The team membership is unavailable.');
29
31
  }
30
- const tickets = (await Promise.all(projectIds.map((projectId) => listProjectTickets(client, projectId)))).flat();
32
+ const tickets = (await Promise.all(projectIds.map((projectId) => listProjectTickets(client, projectId, query)))).flat();
31
33
  const memberSet = new Set(membership.ids);
32
34
  const assigned = tickets.filter((ticket) => ticket.assigneeId && memberSet.has(ticket.assigneeId));
33
35
  const members = membership.ids.map((userId) => {
@@ -1,6 +1,6 @@
1
1
  import type { VNode } from '@opentui/core';
2
2
  import { AsyncScreen } from '../../../Screen/async-screen.js';
3
- import type { FullScreenChrome } from '../../../Screen/full-screen.js';
3
+ import type { FullScreenChrome, ListFilterBadge } from '../../../Screen/full-screen.js';
4
4
  import type { ScreenContext } from '../../../Screen/screen.js';
5
5
  import { type TeamWorkloadResult } from '../Data/workload.js';
6
6
  export declare class TeamWorkScreen extends AsyncScreen<TeamWorkloadResult> {
@@ -19,10 +19,21 @@ export declare class TeamWorkScreen extends AsyncScreen<TeamWorkloadResult> {
19
19
  };
20
20
  };
21
21
  readonly id: string;
22
+ searchQuery: string;
23
+ private readonly search;
24
+ /** No ticket rows here, so only a refusal lands: nothing was found, and the
25
+ * counts say so instead of standing over the previous search. */
26
+ showRows(): void;
27
+ reloadSearch(context: ScreenContext): Promise<boolean>;
22
28
  reload(context: ScreenContext): void;
23
29
  protected load(context: ScreenContext): Promise<TeamWorkloadResult>;
24
30
  protected renderLoading(): string[];
25
31
  protected renderReady(result: TeamWorkloadResult): VNode;
26
32
  protected chrome(context: ScreenContext): FullScreenChrome;
33
+ protected badges(): readonly ListFilterBadge[];
34
+ protected notice(): string | undefined;
35
+ handleInput(input: string, context: ScreenContext): boolean;
36
+ leave(): void;
37
+ reset(): void;
27
38
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
28
39
  }
@@ -3,6 +3,7 @@ import { sectionRowNode, sectionRows, } from '../../../Component/section-list/in
3
3
  import { workloadSummaryRows } from '../../../Component/workload-summary/index.js';
4
4
  import { columnNode } from '../../../lib/vnode.js';
5
5
  import { AsyncScreen } from '../../../Screen/async-screen.js';
6
+ import { TicketSearch } from '../../ticket/Screen/ticket-search.js';
6
7
  import { loadTeamWorkload } from '../Data/workload.js';
7
8
  export class TeamWorkScreen extends AsyncScreen {
8
9
  frameId = 'team-work';
@@ -13,6 +14,24 @@ export class TeamWorkScreen extends AsyncScreen {
13
14
  fallback: { routeId: 'teams' },
14
15
  };
15
16
  id = TeamWorkScreen.route.id;
17
+ searchQuery = '';
18
+ search = new TicketSearch(this);
19
+ /** No ticket rows here, so only a refusal lands: nothing was found, and the
20
+ * counts say so instead of standing over the previous search. */
21
+ showRows() {
22
+ const current = this.readyValue;
23
+ if (!current)
24
+ return;
25
+ const none = { open: 0, running: 0, waiting: 0 };
26
+ this.replaceReady({
27
+ ...current,
28
+ members: current.members.map((member) => ({ ...member, ...none })),
29
+ total: none,
30
+ });
31
+ }
32
+ reloadSearch(context) {
33
+ return this.reloadInPlace(context);
34
+ }
16
35
  reload(context) {
17
36
  this.retry(context);
18
37
  }
@@ -22,7 +41,7 @@ export class TeamWorkScreen extends AsyncScreen {
22
41
  const teamId = context.params?.teamId;
23
42
  if (!teamId)
24
43
  throw new Error('Team Work requires teamId');
25
- return loadTeamWorkload(context.services.client, teamId);
44
+ return loadTeamWorkload(context.services.client, teamId, this.searchQuery);
26
45
  }
27
46
  renderLoading() {
28
47
  return ['Loading team work…'];
@@ -58,9 +77,35 @@ export class TeamWorkScreen extends AsyncScreen {
58
77
  'Team';
59
78
  return {
60
79
  title: `${teamName} · work`,
61
- meta: `${this.readyValue?.team.projectCount ?? 0} project(s) · ${context.services?.baseUrl ?? ''}`,
80
+ meta: [
81
+ `${this.readyValue?.team.projectCount ?? 0} project(s)`,
82
+ context.services?.baseUrl ?? '',
83
+ this.search.hint(),
84
+ ]
85
+ .filter(Boolean)
86
+ .join(' · '),
62
87
  };
63
88
  }
89
+ badges() {
90
+ return [this.search.badge()];
91
+ }
92
+ notice() {
93
+ return this.search.status || undefined;
94
+ }
95
+ handleInput(input, context) {
96
+ if (super.handleInput(input, context))
97
+ return true;
98
+ return this.search.handleKey(input, context);
99
+ }
100
+ leave() {
101
+ this.search.close();
102
+ super.leave();
103
+ }
104
+ reset() {
105
+ this.searchQuery = '';
106
+ this.search.reset();
107
+ super.reset();
108
+ }
64
109
  actions() {
65
110
  return [actionItem('team-work-tab', 'Tab', 'tabs')];
66
111
  }
@@ -9,7 +9,7 @@
9
9
  // convenience: it already implements the project filter plus the free-text
10
10
  // search, so the parent picker cannot drift from the ticket list. How WIDE the
11
11
  // search matches is the caller's choice (`match`): the create form keeps the
12
- // builder's identifier+title OR group (GAIA-257 S5), while the parent FILTER
12
+ // builder's server-resolved identifier+title search (GAIA-257 S5), while the parent FILTER
13
13
  // picker matches by identifier only — the operator knows `GAIA-273`, not the
14
14
  // title (GAIA-284, PM steer during coding) — and asks for tickets WITH
15
15
  // children only (`filter[has_children]=1`, GAIA-300 D1, answered server-side
@@ -57,7 +57,7 @@ export declare function describeCriteria(criteria: TicketCriteria): string;
57
57
  /**
58
58
  * Build the JSON:API query for a project's tickets under the given criteria.
59
59
  * project + state + closed + parent + who are AND filters at the top level; the
60
- * free-text search is an OR group matching identifier OR title (substring).
60
+ * free-text search is one `filter[search]` the server resolves (GAIA-544).
61
61
  */
62
62
  /** How a caller narrows the shared builder (GAIA-292 D1/D8). */
63
63
  export interface TicketQueryOptions {
@@ -92,4 +92,4 @@ export declare function buildTeamMemberTicketQueryParams(userId: string, project
92
92
  export declare function buildConductorTicketQueryParams(conductorId: string, page?: {
93
93
  size: number;
94
94
  index: number;
95
- }): DrupalJsonApiParams;
95
+ }, query?: string): DrupalJsonApiParams;
@@ -100,10 +100,13 @@ export function buildTeamMemberTicketQueryParams(userId, projectIds, criteria, o
100
100
  * open only, newest-changed first. Not project-scoped: a conductor may serve
101
101
  * more than one project.
102
102
  */
103
- export function buildConductorTicketQueryParams(conductorId, page = { size: PAGE_SIZE, index: 0 }) {
103
+ export function buildConductorTicketQueryParams(conductorId, page = { size: PAGE_SIZE, index: 0 }, query = '') {
104
104
  const params = new DrupalJsonApiParams();
105
105
  params.addFilter('conductor_id.id', conductorId);
106
106
  params.addFilter('closed', '0');
107
+ // The `/` line, resolved server-side exactly as on the project list.
108
+ if (query)
109
+ params.addFilter('search', query);
107
110
  params.addInclude(['parent_id', 'conductor_id']);
108
111
  params.addFields('gaia_conductor--gaia_conductor', TICKET_CONDUCTOR_INCLUDE_FIELDS);
109
112
  params.addFields('gaia_ticket--gaia_ticket', TICKET_LIST_FIELDS);
@@ -115,11 +118,12 @@ export function buildConductorTicketQueryParams(conductorId, page = { size: PAGE
115
118
  return params;
116
119
  }
117
120
  function applyTicketCriteria(params, criteria, options) {
118
- if (criteria.query) {
119
- params.addGroup('search', 'OR');
120
- params.addFilter('identifier', criteria.query, 'CONTAINS', 'search');
121
- params.addFilter('title', criteria.query, 'CONTAINS', 'search');
122
- }
121
+ // The typed line goes to the server verbatim (GAIA-544): it resolves
122
+ // `#19`, `gaia#19` and origin URLs to their tickets, and turns anything else
123
+ // into the identifier OR title CONTAINS match this used to build. The grammar
124
+ // lives once, server-side, so the terminal and the web list cannot drift.
125
+ if (criteria.query)
126
+ params.addFilter('search', criteria.query);
123
127
  if (criteria.state)
124
128
  params.addFilter('state', criteria.state);
125
129
  // '1'/'0', never the boolean: the backend's integer-backed boolean storage
@@ -127,16 +131,16 @@ function applyTicketCriteria(params, criteria, options) {
127
131
  if (criteria.closed !== null)
128
132
  params.addFilter('closed', criteria.closed ? '1' : '0');
129
133
  // Direct children only; top level, so it ANDs with project/state while
130
- // the search OR group stays untouched (AC-5).
134
+ // the search stays untouched (AC-5).
131
135
  if (criteria.parent)
132
136
  params.addFilter('parent_id.id', criteria.parent.id);
133
137
  // Assigned to this user only; a top-level AND like parent, never part of
134
- // the search OR group (GAIA-299 AC-4). Skipped when the builder itself is
138
+ // the search (GAIA-299 AC-4). Skipped when the builder itself is
135
139
  // already scoped to an assignee (user ticket list).
136
140
  if (criteria.who)
137
141
  params.addFilter('assignee_user_id.id', criteria.who.id);
138
142
  // Owned by (created by) this user only; a top-level AND like who/parent,
139
- // never part of the search OR group (GAIA-416). `owner_user_id` is the
143
+ // never part of the search (GAIA-416). `owner_user_id` is the
140
144
  // entity's owner key — see `ticket-detail/index.ts` — and is unrelated to
141
145
  // the web-UI's `owner` exposed-Views facet.
142
146
  if (criteria.owner)
@@ -330,7 +330,7 @@ export type TicketPage = Page<TicketRow>;
330
330
  * screen happens to be holding. `null` when the server reports no count, which
331
331
  * `readTotal` already treats as the explicit unknown rather than as a zero.
332
332
  *
333
- * It builds the SAME query as {@link loadFilteredTickets} — the same OR group over
333
+ * It builds the SAME query as {@link loadFilteredTickets} — the same `filter[search]` over
334
334
  * identifier/title, the same project/state/parent/who ANDs — with exactly one
335
335
  * criterion removed. That is what makes the number it returns an answer to "how
336
336
  * many is `closed` holding back" and not to some other question; a second,
@@ -298,7 +298,7 @@ export function mapTicketDocument(document) {
298
298
  * screen happens to be holding. `null` when the server reports no count, which
299
299
  * `readTotal` already treats as the explicit unknown rather than as a zero.
300
300
  *
301
- * It builds the SAME query as {@link loadFilteredTickets} — the same OR group over
301
+ * It builds the SAME query as {@link loadFilteredTickets} — the same `filter[search]` over
302
302
  * identifier/title, the same project/state/parent/who ANDs — with exactly one
303
303
  * criterion removed. That is what makes the number it returns an answer to "how
304
304
  * many is `closed` holding back" and not to some other question; a second,
@@ -112,6 +112,18 @@ export declare const EDIT_FORM_TERMINAL: {
112
112
  apply(s: FormState<EditValues>, options: EditFormOptions, intent: import("../../../Form/form.js").Intent): FormState<EditValues>;
113
113
  toObject: (s: FormState<EditValues>, options: EditFormOptions) => EditFormPayload;
114
114
  };
115
+ /**
116
+ * A closed ticket's popup: read-only, no Save and no Cancel ticket. The server
117
+ * freezes a ticket on its stored `closed` flag and answers any PATCH with 403.
118
+ */
119
+ export declare const EDIT_FORM_CLOSED: {
120
+ fields: import("../../../Form/field.js").FieldDecl[];
121
+ activeField: (s: FormState<EditValues>) => import("../../../Form/field.js").FieldDecl;
122
+ move: (s: FormState<EditValues>, d: number) => FormState<EditValues>;
123
+ initial(options: EditFormOptions, prefill?: Partial<EditValues>): FormState<EditValues>;
124
+ apply(s: FormState<EditValues>, options: EditFormOptions, intent: import("../../../Form/form.js").Intent): FormState<EditValues>;
125
+ toObject: (s: FormState<EditValues>, options: EditFormOptions) => EditFormPayload;
126
+ };
115
127
  export type EditFormDeclaration = typeof EDIT_FORM;
116
128
  /** The ticket's state as the popup sees it; `''` when the read carried none. */
117
129
  export declare const ticketState: (document: TicketDetailDocument) => string;
@@ -127,6 +139,7 @@ export declare const editFormFor: (document: TicketDetailDocument) => EditFormDe
127
139
  /** Field order in the popup, per declaration. */
128
140
  export declare const EDIT_FIELDS: FieldDef<EditFieldId>[];
129
141
  export declare const EDIT_FIELDS_TERMINAL: FieldDef<EditFieldId>[];
142
+ export declare const EDIT_FIELDS_CLOSED: FieldDef<EditFieldId>[];
130
143
  export type EditFormState = FormState<EditValues>;
131
144
  /**
132
145
  * No lists to OFFER — bags start empty until the adapter / option loader fills
@@ -102,7 +102,7 @@ const timeAttributes = (v) => {
102
102
  * every index below it; absence has to be a property of WHICH DECLARATION is in
103
103
  * force.
104
104
  */
105
- const declareEditForm = (cancellable) => declareForm({
105
+ const declareEditForm = (savable, cancellable) => declareForm({
106
106
  fields: (f) => [
107
107
  f.select('assignee', 'Assignee', { from: 'users', clearable: true }),
108
108
  f.select('conductor', 'Conductor', {
@@ -119,7 +119,7 @@ const declareEditForm = (cancellable) => declareForm({
119
119
  pageSize: PARENT_PAGE_SIZE,
120
120
  }),
121
121
  f.multi('labels', 'Labels', { from: 'labels' }),
122
- f.action('Save', 'submit'),
122
+ ...(savable ? [f.action('Save', 'submit')] : []),
123
123
  ...(cancellable
124
124
  ? [f.action('Cancel ticket', CANCEL_TICKET_ACTION_ID)]
125
125
  : []),
@@ -136,9 +136,14 @@ const declareEditForm = (cancellable) => declareForm({
136
136
  }),
137
137
  });
138
138
  /** The ordinary popup: a non-terminal ticket can be cancelled from it. */
139
- export const EDIT_FORM = declareEditForm(true);
139
+ export const EDIT_FORM = declareEditForm(true, true);
140
140
  /** A terminal ticket's popup: assignment, times, structure — no Cancel ticket. */
141
- export const EDIT_FORM_TERMINAL = declareEditForm(false);
141
+ export const EDIT_FORM_TERMINAL = declareEditForm(true, false);
142
+ /**
143
+ * A closed ticket's popup: read-only, no Save and no Cancel ticket. The server
144
+ * freezes a ticket on its stored `closed` flag and answers any PATCH with 403.
145
+ */
146
+ export const EDIT_FORM_CLOSED = declareEditForm(false, false);
142
147
  /** The ticket's state as the popup sees it; `''` when the read carried none. */
143
148
  export const ticketState = (document) => {
144
149
  const value = document.data?.attributes?.state;
@@ -152,12 +157,17 @@ export const ticketState = (document) => {
152
157
  * Cancel ticket on a thin read would cost a legitimate affordance; offering it
153
158
  * costs at worst the server's 422, which the popup already renders in full.
154
159
  */
155
- export const editFormFor = (document) => TERMINAL_TICKET_STATES.has(ticketState(document))
156
- ? EDIT_FORM_TERMINAL
157
- : EDIT_FORM;
160
+ export const editFormFor = (document) => {
161
+ if (document.data?.attributes?.closed === true)
162
+ return EDIT_FORM_CLOSED;
163
+ return TERMINAL_TICKET_STATES.has(ticketState(document))
164
+ ? EDIT_FORM_TERMINAL
165
+ : EDIT_FORM;
166
+ };
158
167
  /** Field order in the popup, per declaration. */
159
168
  export const EDIT_FIELDS = EDIT_FORM.fields;
160
169
  export const EDIT_FIELDS_TERMINAL = EDIT_FORM_TERMINAL.fields;
170
+ export const EDIT_FIELDS_CLOSED = EDIT_FORM_CLOSED.fields;
161
171
  /**
162
172
  * No lists to OFFER — bags start empty until the adapter / option loader fills
163
173
  * them. Prefill names every value, and `clear`/`toObject` read no option list
@@ -1,4 +1,4 @@
1
- import type { FullScreenChrome } from '../../../Screen/full-screen.js';
1
+ import type { FullScreenChrome, ListFilterBadge } from '../../../Screen/full-screen.js';
2
2
  import { ListScreen } from '../../../Screen/list-screen.js';
3
3
  import type { ScreenContext } from '../../../Screen/screen.js';
4
4
  import { type Page } from '../../collection.js';
@@ -69,16 +69,22 @@ export declare class ConductorTicketsScreen extends ListScreen<TicketRow, void>
69
69
  }];
70
70
  readonly emptyMessage: "No ticket is assigned to this conductor.";
71
71
  };
72
+ searchQuery: string;
73
+ private readonly search;
74
+ reloadSearch(context: ScreenContext): Promise<boolean>;
72
75
  protected tableContext(): void;
73
76
  protected loadRows(context: ScreenContext, page: number): Promise<Page<TicketRow>>;
74
77
  protected renderLoading(): string[];
75
78
  protected chrome(): FullScreenChrome;
79
+ protected badges(): readonly ListFilterBadge[];
80
+ protected notice(): string | undefined;
76
81
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
77
82
  activate(index: number, context: ScreenContext): boolean;
78
83
  select(index: number, context: ScreenContext): boolean;
79
84
  handleInput(input: string, context: ScreenContext): boolean;
80
85
  private headerDetail;
81
86
  protected didLoad(_data: Page<TicketRow>, context: ScreenContext): void;
87
+ leave(): void;
82
88
  reset(): void;
83
89
  /** The pipe's own text — the same table + rows the drawn tree uses. */
84
90
  renderNonInteractive(): string[];
@@ -7,6 +7,7 @@ import { conductorTitle } from '../../conductor/Screen/conductor-overview.js';
7
7
  import { LIST_PAGE_SIZE } from '../../cycle.js';
8
8
  import { buildConductorTicketQueryParams } from '../Data/query.js';
9
9
  import { CONDUCTOR_TICKET_TABLE, mapTicketData, } from '../Data/tickets.js';
10
+ import { TicketSearch } from './ticket-search.js';
10
11
  /** Assigned — the open tickets held by one conductor. */
11
12
  export class ConductorTicketsScreen extends ListScreen {
12
13
  static route = {
@@ -19,6 +20,13 @@ export class ConductorTicketsScreen extends ListScreen {
19
20
  id = ConductorTicketsScreen.route.id;
20
21
  frameId = 'conductor';
21
22
  table = CONDUCTOR_TICKET_TABLE;
23
+ searchQuery = '';
24
+ search = new TicketSearch(this);
25
+ reloadSearch(context) {
26
+ this.page = 0;
27
+ this.selectedIndex = 0;
28
+ return this.reloadInPlace(context);
29
+ }
22
30
  tableContext() {
23
31
  // No clock/context needed for this table.
24
32
  }
@@ -27,10 +35,7 @@ export class ConductorTicketsScreen extends ListScreen {
27
35
  throw new Error('Assigned requires services');
28
36
  const conductorId = context.params?.conductorId ?? '';
29
37
  const window = { offset: page * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE };
30
- const params = buildConductorTicketQueryParams(conductorId, {
31
- size: window.limit,
32
- index: page,
33
- });
38
+ const params = buildConductorTicketQueryParams(conductorId, { size: window.limit, index: page }, this.searchQuery);
34
39
  return loadPage(context.services.client, 'gaia_ticket/gaia_ticket', params, (data, included) => {
35
40
  const index = new Map(included.filter((r) => r.id).map((r) => [r.id, r]));
36
41
  return data.map((item) => mapTicketData(item, index));
@@ -45,11 +50,22 @@ export class ConductorTicketsScreen extends ListScreen {
45
50
  selectedIndex: this.selectedIndex,
46
51
  rowIds: this.rowNodeIds,
47
52
  title: conductorTitle(this.headerDetail),
48
- meta: [this.countLabel(), ...this.pagingSegments()]
53
+ meta: [
54
+ [this.countLabel(), ...this.pagingSegments()]
55
+ .filter(Boolean)
56
+ .join(' · '),
57
+ this.search.hint(),
58
+ ]
49
59
  .filter(Boolean)
50
- .join(' · '),
60
+ .join(' · '),
51
61
  };
52
62
  }
63
+ badges() {
64
+ return [this.search.badge()];
65
+ }
66
+ notice() {
67
+ return this.search.status || undefined;
68
+ }
53
69
  actions() {
54
70
  return [
55
71
  ...(this.rows.length > 0
@@ -76,6 +92,8 @@ export class ConductorTicketsScreen extends ListScreen {
76
92
  handleInput(input, context) {
77
93
  if (super.handleInput(input, context))
78
94
  return true;
95
+ if (this.search.handleKey(input, context))
96
+ return true;
79
97
  if (this.handlePagingInput(input, context))
80
98
  return true;
81
99
  if (this.handleCursorInput(input, context))
@@ -94,8 +112,14 @@ export class ConductorTicketsScreen extends ListScreen {
94
112
  context.requestDraw();
95
113
  });
96
114
  }
115
+ leave() {
116
+ this.search.close();
117
+ super.leave();
118
+ }
97
119
  reset() {
98
120
  this.resetList();
121
+ this.searchQuery = '';
122
+ this.search.reset();
99
123
  this.headerDetail = undefined;
100
124
  super.reset();
101
125
  }
@@ -33,7 +33,7 @@ export declare class EditAssignmentFormScreen extends AsyncModalScreen<EditAssig
33
33
  * Chosen once, in `didLoad`, and used for BOTH the rendered rows and the key
34
34
  * routing — the kernel indexes its field array by `state.active`, so two
35
35
  * sources for that array is two ways to disagree about which row the `▸`
36
- * marker is on. Defaults to the terminal (no-cancel) declaration: a screen
36
+ * marker is on. Defaults to the closed (read-only) declaration: a screen
37
37
  * that somehow renders before its document has arrived offers less rather
38
38
  * than more.
39
39
  */
@@ -86,6 +86,8 @@ export declare class EditAssignmentFormScreen extends AsyncModalScreen<EditAssig
86
86
  leave(): void;
87
87
  reset(): void;
88
88
  protected modalChrome(context: ScreenContext): ModalChrome;
89
+ /** Whether this ticket's popup can write at all; a closed one cannot. */
90
+ private get savable();
89
91
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
90
92
  }
91
93
  export {};
@@ -9,7 +9,7 @@ import { activeRunInfo } from '../../run/Data/active-run.js';
9
9
  import { resolveWhoCandidate } from '../../user/Data/users.js';
10
10
  import { EDIT_FORM_REGION, editFormTree, } from '../Component/edit-form/index.js';
11
11
  import { loadTicketDetail } from '../Data/detail.js';
12
- import { CANCEL_TICKET_ACTION_ID, cancelEditPayload, EDIT_FORM_TERMINAL, EMPTY_EDIT_FORM_OPTIONS, editFormFor, initialEditForm, serializeEdit, } from '../Form/edit.js';
12
+ import { CANCEL_TICKET_ACTION_ID, cancelEditPayload, EDIT_FORM_CLOSED, EMPTY_EDIT_FORM_OPTIONS, editFormFor, initialEditForm, serializeEdit, } from '../Form/edit.js';
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';
@@ -44,11 +44,11 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
44
44
  * Chosen once, in `didLoad`, and used for BOTH the rendered rows and the key
45
45
  * routing — the kernel indexes its field array by `state.active`, so two
46
46
  * sources for that array is two ways to disagree about which row the `▸`
47
- * marker is on. Defaults to the terminal (no-cancel) declaration: a screen
47
+ * marker is on. Defaults to the closed (read-only) declaration: a screen
48
48
  * that somehow renders before its document has arrived offers less rather
49
49
  * than more.
50
50
  */
51
- declaration = EDIT_FORM_TERMINAL;
51
+ declaration = EDIT_FORM_CLOSED;
52
52
  form;
53
53
  action;
54
54
  message = '';
@@ -223,12 +223,19 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
223
223
  return true;
224
224
  if (intent.kind === 'cancel')
225
225
  this.action = { type: 'back' };
226
+ else if (intent.kind === 'submit' && !this.savable) {
227
+ // A closed ticket's popup is read-only: Ctrl+S writes nothing.
228
+ this.message = 'This ticket is closed and read-only.';
229
+ }
226
230
  else if (intent.kind === 'submit') {
227
231
  // D10: Ctrl+S ALWAYS Save, even when the cursor is on Cancel ticket.
228
232
  // Only Enter/submit while standing on the cancelTicket action cancels.
229
233
  const cancelTicket = input !== 'ctrl+s' && field.id === CANCEL_TICKET_ACTION_ID;
230
234
  this.submit(context, data, cancelTicket);
231
235
  }
236
+ else if (!this.savable && intent.kind !== 'move') {
237
+ // A closed ticket's values stay as they are; only ↑↓ moves.
238
+ }
232
239
  else {
233
240
  this.form = this.declaration.apply(this.form, data.options, intent);
234
241
  if (field.query !== undefined &&
@@ -258,7 +265,7 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
258
265
  super.leave();
259
266
  }
260
267
  reset() {
261
- this.declaration = EDIT_FORM_TERMINAL;
268
+ this.declaration = EDIT_FORM_CLOSED;
262
269
  this.form = undefined;
263
270
  this.action = undefined;
264
271
  this.message = '';
@@ -279,12 +286,20 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
279
286
  ''),
280
287
  };
281
288
  }
289
+ /** Whether this ticket's popup can write at all; a closed one cannot. */
290
+ get savable() {
291
+ return this.declaration.fields.some((f) => f.id === 'submit');
292
+ }
282
293
  actions() {
283
294
  // GAIA-360 D12 — Esc:close (not cancel); Cancel ticket is lifecycle.
284
295
  return [
285
296
  actionItem('edit-field', '↑↓', 'field'),
286
- actionItem('edit-change', '←→', 'change'),
287
- actionItem('edit-save', 'Ctrl+S', 'save'),
297
+ ...(this.savable
298
+ ? [
299
+ actionItem('edit-change', '←→', 'change'),
300
+ actionItem('edit-save', 'Ctrl+S', 'save'),
301
+ ]
302
+ : []),
288
303
  actionItem('edit-esc', 'Esc', 'close'),
289
304
  ];
290
305
  }
@@ -24,7 +24,12 @@ export declare class MyWorkScreen extends AsyncScreen<MyWorkResult> {
24
24
  private readonly cursor;
25
25
  private currentCriteria;
26
26
  private readonly filter;
27
- private get rows();
27
+ private readonly search;
28
+ get rows(): readonly TicketRow[];
29
+ get searchQuery(): string;
30
+ set searchQuery(query: string);
31
+ showRows(rows: readonly TicketRow[]): void;
32
+ reloadSearch(context: ScreenContext): Promise<boolean>;
28
33
  reload(context: ScreenContext): void;
29
34
  protected load(context: ScreenContext): Promise<MyWorkResult>;
30
35
  protected renderLoading(): string[];
@@ -38,9 +43,11 @@ export declare class MyWorkScreen extends AsyncScreen<MyWorkResult> {
38
43
  * parent · project · closed · clear).
39
44
  */
40
45
  protected badges(): readonly ListFilterBadge[];
46
+ protected notice(): string | undefined;
41
47
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
42
48
  activate(index: number, context: ScreenContext): boolean;
43
49
  handleInput(input: string, context: ScreenContext): boolean;
50
+ leave(): void;
44
51
  reset(): void;
45
52
  get criteria(): TicketCriteria;
46
53
  /** Reload through `retry`, mirroring `TicketsScreen.setCriteria`. */
@@ -6,6 +6,7 @@ import { resolveWhoCandidate } from '../../user/Data/users.js';
6
6
  import { DEFAULT_TICKET_CRITERIA, describeCriteria, } from '../Data/query.js';
7
7
  import { loadFilteredUserTickets, TICKET_TABLE, } from '../Data/tickets.js';
8
8
  import { TicketFilterBar } from './ticket-filter-bar.js';
9
+ import { TicketSearch } from './ticket-search.js';
9
10
  const MY_WORK_TABLE = {
10
11
  ...TICKET_TABLE,
11
12
  emptyMessage: 'No open tickets.',
@@ -23,13 +24,33 @@ export class MyWorkScreen extends AsyncScreen {
23
24
  // GAIA-540: a single bar shared with the project ticket list, disabled for
24
25
  // `who` (My work = my tickets, the criterion is implicit) and `mine` (the
25
26
  // shortcut makes no sense when the assignee is fixed). The bar still
26
- // dispatches `s`/`x`/`p`/`r`/`/`/`c`, and the bar's `applyTo` mirrors the
27
- // project list's guard so a cached re-entry cannot surface a non-matching
28
- // row.
27
+ // dispatches `s`/`x`/`p`/`r`/`c`. The bar's `applyTo` still guards
28
+ // state/closed/parent/project on a cached re-entry; the search line is
29
+ // the server's.
29
30
  filter = new TicketFilterBar(this, new Set(['who', 'mine']));
31
+ search = new TicketSearch(this);
30
32
  get rows() {
31
33
  return this.readyValue?.page.rows ?? [];
32
34
  }
35
+ get searchQuery() {
36
+ return this.currentCriteria.query;
37
+ }
38
+ set searchQuery(query) {
39
+ this.currentCriteria = { ...this.currentCriteria, query };
40
+ }
41
+ showRows(rows) {
42
+ const current = this.readyValue;
43
+ if (!current)
44
+ return;
45
+ this.replaceReady({
46
+ ...current,
47
+ page: { ...current.page, rows: [...rows], total: null, window: null },
48
+ });
49
+ }
50
+ reloadSearch(context) {
51
+ this.cursor.reset();
52
+ return this.reloadInPlace(context);
53
+ }
33
54
  reload(context) {
34
55
  this.retry(context);
35
56
  }
@@ -77,6 +98,7 @@ export class MyWorkScreen extends AsyncScreen {
77
98
  `${this.rows.length} open ticket(s)`,
78
99
  describe,
79
100
  context.services?.workspace ?? '',
101
+ this.search.hint(),
80
102
  ]
81
103
  .filter(Boolean)
82
104
  .join(' · ');
@@ -97,6 +119,9 @@ export class MyWorkScreen extends AsyncScreen {
97
119
  badges() {
98
120
  return this.filter.badges();
99
121
  }
122
+ notice() {
123
+ return this.search.status || undefined;
124
+ }
100
125
  actions() {
101
126
  return [
102
127
  actionItem('my-work-open', 'Enter', 'open'),
@@ -113,7 +138,10 @@ export class MyWorkScreen extends AsyncScreen {
113
138
  handleInput(input, context) {
114
139
  if (super.handleInput(input, context))
115
140
  return true;
116
- // The bar first: its key dispatch covers `s`/`x`/`p`/`r`/`/`/`c`. The
141
+ // The open search field takes every key, so it goes before the bar.
142
+ if (this.search.handleKey(input, context))
143
+ return true;
144
+ // The bar next: its key dispatch covers `s`/`x`/`p`/`r`/`c`. The
117
145
  // cursor/enter branches stay below so the bar claims only the keys it
118
146
  // names.
119
147
  if (this.filter.handleKey(input, context))
@@ -126,7 +154,12 @@ export class MyWorkScreen extends AsyncScreen {
126
154
  return this.activate(this.cursor.index, context);
127
155
  return false;
128
156
  }
157
+ leave() {
158
+ this.search.close();
159
+ super.leave();
160
+ }
129
161
  reset() {
162
+ this.search.reset();
130
163
  this.cursor.reset();
131
164
  this.currentCriteria = { ...DEFAULT_TICKET_CRITERIA };
132
165
  super.reset();