@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
@@ -105,18 +105,11 @@ export class TicketFilterBar {
105
105
  }
106
106
  return false;
107
107
  }
108
- if (!this.disable.has('search') && input === '/') {
109
- return true;
110
- }
111
108
  return false;
112
109
  }
113
110
  applyTo(row) {
114
- const query = this.host.criteria.query.toLocaleLowerCase();
115
- return ((!query ||
116
- row.identifier.toLocaleLowerCase().includes(query) ||
117
- row.title.toLocaleLowerCase().includes(query)) &&
118
- (this.host.criteria.state === null ||
119
- row.state === this.host.criteria.state) &&
111
+ return ((this.host.criteria.state === null ||
112
+ row.state === this.host.criteria.state) &&
120
113
  (this.host.criteria.closed === null ||
121
114
  row.closed === this.host.criteria.closed) &&
122
115
  (this.host.criteria.parent === null ||
@@ -0,0 +1,44 @@
1
+ import type { ListFilterBadge } from '../../../Screen/full-screen.js';
2
+ import type { ScreenContext } from '../../../Screen/screen.js';
3
+ import type { TicketRow } from '../Data/tickets.js';
4
+ export interface TicketSearchHost {
5
+ /** The typed line; the host's loader sends it as `filter[search]`. */
6
+ searchQuery: string;
7
+ /** The rows on screen, narrowed locally while the server read is in flight.
8
+ * Absent on a screen that shows no ticket rows (Team → Work). */
9
+ readonly rows?: readonly TicketRow[];
10
+ /** Show these rows until the server answers; `[]` after a refusal. */
11
+ showRows(rows: readonly TicketRow[]): void;
12
+ /** Re-read for the current query — `AsyncScreen.reloadInPlace` plus any
13
+ * criteria-change bookkeeping (page one, cursor). */
14
+ reloadSearch(context: ScreenContext): Promise<boolean>;
15
+ }
16
+ /**
17
+ * The `/` search every terminal ticket list shares (GAIA-544 AC9).
18
+ *
19
+ * Composed, not inherited: the lists sit on `ListScreen` and on bare
20
+ * `AsyncScreen`. The server resolves the line — identifiers and fragments,
21
+ * origin references (`#19`, `gaia#19`, URLs) — so the screen only narrows
22
+ * optimistically, debounces the reload, drops stale answers (through
23
+ * `reloadInPlace`) and states a refusal as the server's sentence over an
24
+ * emptied list, only while that line is still the query.
25
+ */
26
+ export declare class TicketSearch {
27
+ private readonly host;
28
+ open: boolean;
29
+ private refusal;
30
+ private refusedQuery;
31
+ private base;
32
+ private cancel;
33
+ constructor(host: TicketSearchHost);
34
+ /** The server's refusal of the current line, or ''. */
35
+ get status(): string;
36
+ badge(): ListFilterBadge;
37
+ /** The meta-line hint while the field is open, or ''. */
38
+ hint(): string;
39
+ /** Claims `/` to open the field and, while it is open, every key. */
40
+ handleKey(input: string, context: ScreenContext): boolean;
41
+ /** Close the field and drop a pending reload — on leave and reset. */
42
+ close(): void;
43
+ reset(): void;
44
+ }
@@ -0,0 +1,97 @@
1
+ import { serverMessage } from '../../../lib/jsonapi-error.js';
2
+ import { timerDebounce } from '../../../Widget/option-source.js';
3
+ /**
4
+ * The `/` search every terminal ticket list shares (GAIA-544 AC9).
5
+ *
6
+ * Composed, not inherited: the lists sit on `ListScreen` and on bare
7
+ * `AsyncScreen`. The server resolves the line — identifiers and fragments,
8
+ * origin references (`#19`, `gaia#19`, URLs) — so the screen only narrows
9
+ * optimistically, debounces the reload, drops stale answers (through
10
+ * `reloadInPlace`) and states a refusal as the server's sentence over an
11
+ * emptied list, only while that line is still the query.
12
+ */
13
+ export class TicketSearch {
14
+ host;
15
+ open = false;
16
+ refusal = '';
17
+ refusedQuery = '';
18
+ base = [];
19
+ cancel;
20
+ constructor(host) {
21
+ this.host = host;
22
+ }
23
+ /** The server's refusal of the current line, or ''. */
24
+ get status() {
25
+ return this.host.searchQuery === this.refusedQuery ? this.refusal : '';
26
+ }
27
+ badge() {
28
+ const query = this.host.searchQuery;
29
+ return query
30
+ ? { id: 'search', label: '/ search', value: `search: ${query}` }
31
+ : { id: 'search', label: '/ search' };
32
+ }
33
+ /** The meta-line hint while the field is open, or ''. */
34
+ hint() {
35
+ return this.open
36
+ ? 'searching IDENTIFIER + TITLE + ORIGIN · type to filter · Backspace erase · Enter keep · Esc leave'
37
+ : '';
38
+ }
39
+ /** Claims `/` to open the field and, while it is open, every key. */
40
+ handleKey(input, context) {
41
+ if (!this.open) {
42
+ if (input !== '/')
43
+ return false;
44
+ this.open = true;
45
+ this.base = [...(this.host.rows ?? [])];
46
+ context.requestDraw();
47
+ return true;
48
+ }
49
+ if (input === 'escape' || input === 'enter') {
50
+ this.open = false;
51
+ context.requestDraw();
52
+ return true;
53
+ }
54
+ const query = this.host.searchQuery;
55
+ if (input === 'backspace')
56
+ this.host.searchQuery = query.slice(0, -1);
57
+ else if (input.length === 1 && input >= ' ')
58
+ this.host.searchQuery = query + input;
59
+ else
60
+ return true;
61
+ if (this.host.rows !== undefined) {
62
+ const needle = this.host.searchQuery.toLocaleLowerCase();
63
+ this.host.showRows(this.base.filter((row) => row.identifier.toLocaleLowerCase().includes(needle) ||
64
+ row.title.toLocaleLowerCase().includes(needle)));
65
+ }
66
+ this.cancel?.();
67
+ const debounce = context.options?.debounce ?? timerDebounce;
68
+ this.cancel = debounce(() => {
69
+ void this.host.reloadSearch(context).then((current) => {
70
+ if (!current)
71
+ return;
72
+ this.refusal = '';
73
+ this.refusedQuery = '';
74
+ context.requestDraw();
75
+ }, (caught) => {
76
+ this.refusal = serverMessage(caught);
77
+ this.refusedQuery = this.host.searchQuery;
78
+ this.host.showRows([]);
79
+ context.requestDraw();
80
+ });
81
+ }, 200);
82
+ context.requestDraw();
83
+ return true;
84
+ }
85
+ /** Close the field and drop a pending reload — on leave and reset. */
86
+ close() {
87
+ this.open = false;
88
+ this.cancel?.();
89
+ this.cancel = undefined;
90
+ }
91
+ reset() {
92
+ this.close();
93
+ this.refusal = '';
94
+ this.refusedQuery = '';
95
+ this.base = [];
96
+ }
97
+ }
@@ -84,10 +84,12 @@ export declare class TicketsScreen extends ListScreen<TicketRow, void> {
84
84
  receiveResult(result: unknown, context: ScreenContext): void;
85
85
  private openingEdit;
86
86
  private status;
87
- private searchMode;
88
- private cancelSearch;
89
- private searchBase;
87
+ private readonly search;
90
88
  leave(): void;
89
+ get searchQuery(): string;
90
+ set searchQuery(query: string);
91
+ /** The search is a criteria change like any other (D5): page one. */
92
+ reloadSearch(context: ScreenContext): Promise<boolean>;
91
93
  row(id: string): TicketRow | undefined;
92
94
  selectId(id: string): boolean;
93
95
  saveState(): {
@@ -169,10 +171,10 @@ export declare class TicketsScreen extends ListScreen<TicketRow, void> {
169
171
  * for the criteria on screen right now. */
170
172
  private hiddenNotice;
171
173
  /**
172
- * The server is authoritative for filtering. Applying the same predicates to
173
- * its bounded page also protects a cached re-entry from clients/test doubles
174
- * that ignore query parameters; it cannot add a row the server did not return
175
- * or change the page/include contract.
174
+ * Re-apply the bar's criteria except the search line: the server already
175
+ * resolved `filter[search]`, including origin references whose identifier
176
+ * and title omit the typed text. The rest protects a cached re-entry from
177
+ * clients that ignore the other query parameters.
176
178
  */
177
179
  protected filterRow(row: TicketRow): boolean;
178
180
  /** Every criteria change starts over at page one: a narrowed filter applied on
@@ -2,13 +2,13 @@ import { actionItem } from '../../../Component/footer-actions/index.js';
2
2
  import { copyThrough } from '../../../lib/clipboard.js';
3
3
  import { serverMessage } from '../../../lib/jsonapi-error.js';
4
4
  import { ListScreen } from '../../../Screen/list-screen.js';
5
- import { timerDebounce } from '../../../Widget/option-source.js';
6
5
  import { resolveWhoCandidate } from '../../user/Data/users.js';
7
6
  import { loadTicketDetail } from '../Data/detail.js';
8
7
  import { CLOSED_FILTER_VALUES, cycleValue, DEFAULT_TICKET_CRITERIA, STATE_FILTER_VALUES, } from '../Data/query.js';
9
8
  import { countTicketsIgnoringClosed, loadFilteredTickets, mapTicketDocument, TICKET_TABLE, } from '../Data/tickets.js';
10
9
  import { ticketUrl } from '../Data/url.js';
11
10
  import { TicketFilterBar } from './ticket-filter-bar.js';
11
+ import { TicketSearch } from './ticket-search.js';
12
12
  export class TicketsScreen extends ListScreen {
13
13
  frameId = 'tickets';
14
14
  table = TICKET_TABLE;
@@ -105,18 +105,22 @@ export class TicketsScreen extends ListScreen {
105
105
  }
106
106
  openingEdit = false;
107
107
  status = '';
108
- searchMode = false;
109
- cancelSearch;
110
- searchBase = [];
108
+ search = new TicketSearch(this);
111
109
  leave() {
112
- const cancelPendingSearch = this.searchMode;
113
- this.searchMode = false;
114
- if (cancelPendingSearch) {
115
- this.cancelSearch?.();
116
- this.cancelSearch = undefined;
117
- }
110
+ this.search.close();
118
111
  super.leave();
119
112
  }
113
+ get searchQuery() {
114
+ return this.criteria.query;
115
+ }
116
+ set searchQuery(query) {
117
+ this.criteria = { ...this.criteria, query };
118
+ }
119
+ /** The search is a criteria change like any other (D5): page one. */
120
+ reloadSearch(context) {
121
+ this.page = 0;
122
+ return this.reloadInPlace(context);
123
+ }
120
124
  row(id) {
121
125
  return this.readyValue?.rows.find((candidate) => candidate.id === id);
122
126
  }
@@ -285,10 +289,10 @@ export class TicketsScreen extends ListScreen {
285
289
  return `${hidden.count} match${hidden.count === 1 ? '' : 'es'} hidden by closed — press x`;
286
290
  }
287
291
  /**
288
- * The server is authoritative for filtering. Applying the same predicates to
289
- * its bounded page also protects a cached re-entry from clients/test doubles
290
- * that ignore query parameters; it cannot add a row the server did not return
291
- * or change the page/include contract.
292
+ * Re-apply the bar's criteria except the search line: the server already
293
+ * resolved `filter[search]`, including origin references whose identifier
294
+ * and title omit the typed text. The rest protects a cached re-entry from
295
+ * clients that ignore the other query parameters.
292
296
  */
293
297
  filterRow(row) {
294
298
  return this.filter.applyTo(row);
@@ -387,7 +391,7 @@ export class TicketsScreen extends ListScreen {
387
391
  this.criteria.who?.id === this.mine.id;
388
392
  const badge = (id, label, value) => (value ? { id, label, value } : { id, label });
389
393
  return [
390
- badge('search', '/ search', this.criteria.query ? `search: ${this.criteria.query}` : undefined),
394
+ this.search.badge(),
391
395
  badge('state', 's state', this.criteria.state ? `state: ${this.criteria.state}` : undefined),
392
396
  // Label, never the uuid (GAIA-299 D3) — including when mine set who.
393
397
  badge('who', 'w who', this.criteria.who ? `who: ${this.criteria.who.label}` : undefined),
@@ -416,9 +420,7 @@ export class TicketsScreen extends ListScreen {
416
420
  .filter(Boolean)
417
421
  .join(' · ');
418
422
  const hidden = this.hiddenNotice();
419
- const searchOpen = this.searchMode
420
- ? 'searching IDENTIFIER + TITLE · type to filter · Backspace erase · Enter keep · Esc leave'
421
- : '';
423
+ const searchOpen = this.search.hint();
422
424
  return {
423
425
  selectable: this.rows.length,
424
426
  selectedIndex: this.selectedIndex,
@@ -435,7 +437,7 @@ export class TicketsScreen extends ListScreen {
435
437
  ];
436
438
  }
437
439
  notice() {
438
- return this.status || undefined;
440
+ return this.search.status || this.status || undefined;
439
441
  }
440
442
  activate(index, context) {
441
443
  const row = this.readyValue?.rows[index];
@@ -456,67 +458,8 @@ export class TicketsScreen extends ListScreen {
456
458
  return true;
457
459
  if (this.openingEdit)
458
460
  return true;
459
- if (this.searchMode) {
460
- if (input === 'escape' || input === 'enter') {
461
- this.searchMode = false;
462
- context.requestDraw();
463
- return true;
464
- }
465
- if (input === 'backspace')
466
- this.criteria = {
467
- ...this.criteria,
468
- query: this.criteria.query.slice(0, -1),
469
- };
470
- else if (input.length === 1 && input >= ' ')
471
- this.criteria = {
472
- ...this.criteria,
473
- query: this.criteria.query + input,
474
- };
475
- else
476
- return true;
477
- const query = this.criteria.query.toLocaleLowerCase();
478
- const narrowed = this.searchBase.filter((row) => row.identifier.toLocaleLowerCase().includes(query) ||
479
- row.title.toLocaleLowerCase().includes(query));
480
- // Optimistic local narrowing until the debounced reload lands; it keeps
481
- // the page it is narrowing — the LOADED one, the page these rows came
482
- // from — so neither `hasNext` nor the page number can flip on a keystroke.
483
- this.replaceReady({
484
- hasNext: this.readyValue?.hasNext ?? false,
485
- page: this.loadedPage,
486
- // The criteria just changed, so the previous collection's total must not
487
- // stand beside these narrowed rows (AC 8.3). Nothing has been established
488
- // for the new collection yet, so the line states the explicit unknown
489
- // (`N of ?`, D6d) until the debounced reload's page answers with its own
490
- // `meta.count`.
491
- total: null,
492
- // Same rule as `ListScreen.load()` (GAIA-312 D4): a narrowing that
493
- // dropped rows scatters the survivors through the window, so the window
494
- // stops positioning them; one that dropped nothing keeps it.
495
- window: narrowed.length === this.searchBase.length
496
- ? (this.readyValue?.window ?? null)
497
- : null,
498
- rows: narrowed,
499
- });
500
- this.cancelSearch?.();
501
- const debounce = context.options?.debounce ?? timerDebounce;
502
- this.cancelSearch = debounce(() => {
503
- // The search is a criteria change like any other (D5).
504
- this.page = 0;
505
- const guard = this.captureLifecycle(context, true);
506
- void this.load(context).then((rows) => {
507
- if (this.replaceReadyIfCurrent(guard, rows, context))
508
- this.requestActiveDraw(context);
509
- });
510
- }, 200);
511
- context.requestDraw();
512
- return true;
513
- }
514
- if (input === '/') {
515
- this.searchMode = true;
516
- this.searchBase = [...this.rows];
517
- context.requestDraw();
461
+ if (this.search.handleKey(input, context))
518
462
  return true;
519
- }
520
463
  if (input === 'e') {
521
464
  const ticketId = this.rows[this.selectedIndex]?.id;
522
465
  if (!ticketId || this.openingEdit || !context.services)
@@ -543,7 +486,7 @@ export class TicketsScreen extends ListScreen {
543
486
  return true;
544
487
  }
545
488
  // The paging arrows, dispatched HERE and not by the base (GAIA-294 D3). The
546
- // position is the load-bearing part: they are reached only PAST the searchMode
489
+ // position is the load-bearing part: they are reached only PAST the search
547
490
  // branch above, which consumes them, so while the `/` field is open the arrows
548
491
  // stay inert — not appended as text (its text branch takes single characters
549
492
  // only) and not paging underneath the field. A base that claimed them first
@@ -555,7 +498,7 @@ export class TicketsScreen extends ListScreen {
555
498
  if (this.handlePagingInput(input, context))
556
499
  return true;
557
500
  // GAIA-326 — the copy keys, and their POSITION is the whole reason `y` is
558
- // safe: they are reached only past the `searchMode` branch above, which
501
+ // safe: they are reached only past the search branch above, which
559
502
  // consumes every printable character, so `y` typed into the `/` field is a
560
503
  // letter and never a copy. Nothing else in `src/` binds `y` or `Y`.
561
504
  if (input === 'y' || input === 'Y') {
@@ -632,10 +575,7 @@ export class TicketsScreen extends ListScreen {
632
575
  this.mine = undefined;
633
576
  this.openingEdit = false;
634
577
  this.status = '';
635
- this.searchMode = false;
636
- this.cancelSearch?.();
637
- this.cancelSearch = undefined;
638
- this.searchBase = [];
578
+ this.search.reset();
639
579
  this.hiddenByClosed = null;
640
580
  super.reset();
641
581
  }
@@ -69,16 +69,23 @@ export declare abstract class MemberTicketsScreen extends ListScreen<TicketRow,
69
69
  readonly emptyMessage: "No tickets.";
70
70
  };
71
71
  private criteria;
72
+ private readonly search;
73
+ get searchQuery(): string;
74
+ set searchQuery(query: string);
75
+ reloadSearch(context: ScreenContext): Promise<boolean>;
72
76
  protected tableContext(): void;
73
77
  protected abstract loadRows(context: ScreenContext, page: number): Promise<TicketPage>;
74
78
  protected ticketCriteria(): TicketCriteria;
75
79
  protected filterRow(row: TicketRow): boolean;
76
80
  protected renderLoading(): string[];
77
81
  protected badges(): readonly ListFilterBadge[];
82
+ protected notice(): string | undefined;
78
83
  protected chrome(context: ScreenContext): FullScreenChrome;
79
84
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
80
85
  activate(index: number, context: ScreenContext): boolean;
81
86
  handleInput(input: string, context: ScreenContext): boolean;
87
+ leave(): void;
88
+ reset(): void;
82
89
  }
83
90
  export declare class WorkspaceMemberTicketsScreen extends MemberTicketsScreen {
84
91
  static readonly route: {
@@ -4,11 +4,24 @@ import { CLOSED_FILTER_VALUES, cycleValue } from '../../cycle.js';
4
4
  import { assertActiveMember } from '../../user/Data/member-context.js';
5
5
  import { DEFAULT_TICKET_CRITERIA } from '../Data/query.js';
6
6
  import { loadUserTickets, TICKET_TABLE, } from '../Data/tickets.js';
7
+ import { TicketSearch } from './ticket-search.js';
7
8
  /** Shared member ticket list behavior; subclasses own scope and loading. */
8
9
  export class MemberTicketsScreen extends ListScreen {
9
10
  frameId = 'user-tickets';
10
11
  table = TICKET_TABLE;
11
12
  criteria = { ...DEFAULT_TICKET_CRITERIA };
13
+ search = new TicketSearch(this);
14
+ get searchQuery() {
15
+ return this.criteria.query;
16
+ }
17
+ set searchQuery(query) {
18
+ this.criteria = { ...this.criteria, query };
19
+ }
20
+ reloadSearch(context) {
21
+ this.page = 0;
22
+ this.selectedIndex = 0;
23
+ return this.reloadInPlace(context);
24
+ }
12
25
  tableContext() { }
13
26
  ticketCriteria() {
14
27
  return this.criteria;
@@ -21,9 +34,10 @@ export class MemberTicketsScreen extends ListScreen {
21
34
  }
22
35
  badges() {
23
36
  if (this.criteria.closed === null) {
24
- return [{ id: 'closed', label: 'x closed' }];
37
+ return [this.search.badge(), { id: 'closed', label: 'x closed' }];
25
38
  }
26
39
  return [
40
+ this.search.badge(),
27
41
  {
28
42
  id: 'closed',
29
43
  label: 'x closed',
@@ -31,6 +45,9 @@ export class MemberTicketsScreen extends ListScreen {
31
45
  },
32
46
  ];
33
47
  }
48
+ notice() {
49
+ return this.search.status || undefined;
50
+ }
34
51
  chrome(context) {
35
52
  const name = context.user?.name ?? context.params?.userName ?? 'user';
36
53
  const pagingLine = [this.countLabel(), ...this.pagingSegments()]
@@ -41,7 +58,7 @@ export class MemberTicketsScreen extends ListScreen {
41
58
  selectedIndex: this.selectedIndex,
42
59
  rowIds: this.rowNodeIds,
43
60
  title: `${name} · tickets`,
44
- meta: pagingLine,
61
+ meta: [pagingLine, this.search.hint()].filter(Boolean).join(' · '),
45
62
  };
46
63
  }
47
64
  actions() {
@@ -59,6 +76,8 @@ export class MemberTicketsScreen extends ListScreen {
59
76
  return true;
60
77
  }
61
78
  handleInput(input, context) {
79
+ if (this.search.handleKey(input, context))
80
+ return true;
62
81
  if (input === 'x') {
63
82
  this.criteria = {
64
83
  ...this.criteria,
@@ -76,6 +95,15 @@ export class MemberTicketsScreen extends ListScreen {
76
95
  return this.activate(this.selectedIndex, context);
77
96
  return super.handleInput(input, context);
78
97
  }
98
+ leave() {
99
+ this.search.close();
100
+ super.leave();
101
+ }
102
+ reset() {
103
+ this.criteria = { ...this.criteria, query: '' };
104
+ this.search.reset();
105
+ super.reset();
106
+ }
79
107
  }
80
108
  export class WorkspaceMemberTicketsScreen extends MemberTicketsScreen {
81
109
  static route = {
@@ -80,6 +80,13 @@ export declare abstract class AsyncScreen<T> extends BaseScreen {
80
80
  */
81
81
  protected actionStillCurrent(guard: LifecycleGuard, context: ScreenContext): boolean;
82
82
  protected replaceReadyIfCurrent(guard: LifecycleGuard, data: T, context: ScreenContext): boolean;
83
+ /**
84
+ * Re-read over the ready surface, superseding any read still in flight.
85
+ * Resolves `false` when a newer read or a local mutation overtook this one;
86
+ * rejects only while it is still the current read, so a stale failure
87
+ * cannot overwrite a newer answer.
88
+ */
89
+ protected reloadInPlace(context: ScreenContext): Promise<boolean>;
83
90
  protected requestActiveDraw(context: ScreenContext): boolean;
84
91
  enter(context: ScreenContext): void;
85
92
  retry(context: ScreenContext): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { filterBadgeStrip, } from '../Component/list-chrome/index.js';
2
+ import { serverMessage } from '../lib/jsonapi-error.js';
2
3
  import { columnNode, messageTree } from '../lib/vnode.js';
3
4
  import { BaseScreen, presentation, } from './screen.js';
4
5
  /**
@@ -103,6 +104,28 @@ export class AsyncScreen extends BaseScreen {
103
104
  this.state = 'ready';
104
105
  return true;
105
106
  }
107
+ /**
108
+ * Re-read over the ready surface, superseding any read still in flight.
109
+ * Resolves `false` when a newer read or a local mutation overtook this one;
110
+ * rejects only while it is still the current read, so a stale failure
111
+ * cannot overwrite a newer answer.
112
+ */
113
+ async reloadInPlace(context) {
114
+ const guard = this.captureLifecycle(context, true);
115
+ let data;
116
+ try {
117
+ data = await this.load(context);
118
+ }
119
+ catch (caught) {
120
+ if (!this.lifecycleIsCurrent(guard, context))
121
+ return false;
122
+ throw caught;
123
+ }
124
+ if (!this.replaceReadyIfCurrent(guard, data, context))
125
+ return false;
126
+ this.requestActiveDraw(context);
127
+ return true;
128
+ }
106
129
  requestActiveDraw(context) {
107
130
  if (!context.isActive()) {
108
131
  this.staleRedrawCount++;
@@ -167,13 +190,11 @@ export class AsyncScreen extends BaseScreen {
167
190
  return;
168
191
  this.refreshing = false;
169
192
  if (keepSurface) {
170
- this.toast =
171
- caught instanceof Error ? caught.message : String(caught);
193
+ this.toast = serverMessage(caught);
172
194
  this.requestActiveDraw(context);
173
195
  return;
174
196
  }
175
- this.failure =
176
- caught instanceof Error ? caught : new Error(String(caught));
197
+ this.failure = new Error(serverMessage(caught));
177
198
  this.state = 'error';
178
199
  this.requestActiveDraw(context);
179
200
  });
@@ -14,7 +14,7 @@ import type { ScreenContext } from './screen.js';
14
14
  * enough hooks to be a framework.
15
15
  *
16
16
  * It does not claim keys from `handleInput` either. The current dispatch ORDER is
17
- * load-bearing: `←`/`→` sit after the ticket list's `searchMode` branch, where the
17
+ * load-bearing: `←`/`→` sit after the ticket list's `/` search branch, where the
18
18
  * arrows are inert while the `/` field is open. A base that claimed them first
19
19
  * would make them page there — a behaviour change, and GAIA-294 is
20
20
  * behaviour-neutral. So the dispatch TABLE lives here once and each subclass calls
@@ -93,7 +93,7 @@ export declare abstract class ListScreen<Row extends {
93
93
  * `←` forwards too (GAIA-292 R1).
94
94
  *
95
95
  * WHERE it is called stays the subclass's decision (GAIA-294 D3) — on the ticket
96
- * list it must sit after the `searchMode` branch, where the arrows are inert
96
+ * list it must sit after the `/` search branch, where the arrows are inert
97
97
  * while the `/` field is open.
98
98
  */
99
99
  protected handlePagingInput(input: string, context: ScreenContext): boolean;
@@ -103,6 +103,15 @@ export declare abstract class ListScreen<Row extends {
103
103
  * chain continues.
104
104
  */
105
105
  protected handleCursorInput(input: string, context: ScreenContext): boolean;
106
+ /**
107
+ * Show `rows` in place of the loaded page until the next read answers — the
108
+ * optimistic narrowing of a search. It keeps the LOADED page number so a
109
+ * keystroke does not flip `page N`; the total becomes the explicit unknown
110
+ * (`N of ?`); a narrowing that dropped rows stops the window positioning
111
+ * the survivors (GAIA-312 D4). An emptied list (a refusal) also drops
112
+ * `hasNext`, so `→` cannot re-send the refused query.
113
+ */
114
+ showRows(rows: readonly Row[]): void;
106
115
  /** `page N` for the strip above the table (count sits beside it). */
107
116
  protected pagingSegments(): string[];
108
117
  /**
@@ -24,7 +24,7 @@ import { AsyncScreen } from './async-screen.js';
24
24
  * enough hooks to be a framework.
25
25
  *
26
26
  * It does not claim keys from `handleInput` either. The current dispatch ORDER is
27
- * load-bearing: `←`/`→` sit after the ticket list's `searchMode` branch, where the
27
+ * load-bearing: `←`/`→` sit after the ticket list's `/` search branch, where the
28
28
  * arrows are inert while the `/` field is open. A base that claimed them first
29
29
  * would make them page there — a behaviour change, and GAIA-294 is
30
30
  * behaviour-neutral. So the dispatch TABLE lives here once and each subclass calls
@@ -92,7 +92,7 @@ export class ListScreen extends AsyncScreen {
92
92
  * `←` forwards too (GAIA-292 R1).
93
93
  *
94
94
  * WHERE it is called stays the subclass's decision (GAIA-294 D3) — on the ticket
95
- * list it must sit after the `searchMode` branch, where the arrows are inert
95
+ * list it must sit after the `/` search branch, where the arrows are inert
96
96
  * while the `/` field is open.
97
97
  */
98
98
  handlePagingInput(input, context) {
@@ -116,6 +116,24 @@ export class ListScreen extends AsyncScreen {
116
116
  context.requestDraw();
117
117
  return true;
118
118
  }
119
+ /**
120
+ * Show `rows` in place of the loaded page until the next read answers — the
121
+ * optimistic narrowing of a search. It keeps the LOADED page number so a
122
+ * keystroke does not flip `page N`; the total becomes the explicit unknown
123
+ * (`N of ?`); a narrowing that dropped rows stops the window positioning
124
+ * the survivors (GAIA-312 D4). An emptied list (a refusal) also drops
125
+ * `hasNext`, so `→` cannot re-send the refused query.
126
+ */
127
+ showRows(rows) {
128
+ const current = this.readyValue;
129
+ this.replaceReady({
130
+ hasNext: rows.length === 0 ? false : (current?.hasNext ?? false),
131
+ page: this.loadedPage,
132
+ total: null,
133
+ window: current && rows.length === current.rows.length ? current.window : null,
134
+ rows: [...rows],
135
+ });
136
+ }
119
137
  /** `page N` for the strip above the table (count sits beside it). */
120
138
  pagingSegments() {
121
139
  return [`page ${this.loadedPage + 1}`];