@gaia-ai/addon-gaia-ui 0.19.0 → 0.20.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.
@@ -26,6 +26,16 @@ export interface Conductor {
26
26
  * contract, because it already answers a failure by falling back.
27
27
  */
28
28
  export declare const listConductors: (client: DropshClient) => Promise<ListOutcome<Conductor>>;
29
+ /**
30
+ * The conductor collection scoped to one project (GAIA-664 review F4).
31
+ *
32
+ * The create popup's narrow option loader, not `ConductorQuery`'s row-page
33
+ * read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
34
+ * outcome/error contract rather than inventing a second one for the same
35
+ * entity. An unscoped caller keeps calling {@link listConductors}; this is
36
+ * additive.
37
+ */
38
+ export declare const listConductorsForProject: (client: DropshClient, projectId: string) => Promise<ListOutcome<Conductor>>;
29
39
  /**
30
40
  * The same collection read through the WRITE client where there is one — still just
31
41
  * reading, but the write scope is the one that decides what a write may reference,
@@ -8,7 +8,7 @@ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
8
8
  import { humanizeAge } from '../../../lib/format.js';
9
9
  import { loadPage, } from '../../collection.js';
10
10
  import { LIST_PAGE_SIZE } from '../../cycle.js';
11
- import { listOutcome, listPreferringWrite, } from '../../transport.js';
11
+ import { listOutcome, listPreferringWrite, messageOf, } from '../../transport.js';
12
12
  import { deriveConductorStatus } from './liveness.js';
13
13
  const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
14
14
  const toConductor = (r) => ({
@@ -32,6 +32,28 @@ const toConductor = (r) => ({
32
32
  * contract, because it already answers a failure by falling back.
33
33
  */
34
34
  export const listConductors = (client) => listOutcome(client, 'gaia_conductor', toConductor);
35
+ /**
36
+ * The conductor collection scoped to one project (GAIA-664 review F4).
37
+ *
38
+ * The create popup's narrow option loader, not `ConductorQuery`'s row-page
39
+ * read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
40
+ * outcome/error contract rather than inventing a second one for the same
41
+ * entity. An unscoped caller keeps calling {@link listConductors}; this is
42
+ * additive.
43
+ */
44
+ export const listConductorsForProject = async (client, projectId) => {
45
+ try {
46
+ return {
47
+ rows: (await client
48
+ .collection('gaia_conductor')
49
+ .where('project_id.id', '=', projectId)
50
+ .list()).map(toConductor),
51
+ };
52
+ }
53
+ catch (caught) {
54
+ return { rows: [], error: messageOf(caught) };
55
+ }
56
+ };
35
57
  /**
36
58
  * The same collection read through the WRITE client where there is one — still just
37
59
  * reading, but the write scope is the one that decides what a write may reference,
@@ -1,5 +1,5 @@
1
1
  import type { VNode } from '@opentui/core';
2
- import { type CreateFormOptions, type CreateFormState } from '../../Form/create.js';
2
+ import type { CreateFieldId, CreateFormOptions, CreateFormState, FieldDef } from '../../Form/create.js';
3
3
  /** The chrome the kernel wraps this tree in. */
4
4
  export interface CreateFormChrome {
5
5
  /**
@@ -25,4 +25,4 @@ export interface CreateFormChrome {
25
25
  /** The id the popup's body column is rooted at. */
26
26
  export declare const CREATE_FORM_REGION = "create-form";
27
27
  /** Render the "New ticket" popup as the tree the kernel frames. */
28
- export declare function createFormTree(s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
28
+ export declare function createFormTree(fields: FieldDef<CreateFieldId>[], s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
@@ -17,7 +17,6 @@
17
17
  // to rebase again onto its own.
18
18
  import { columnNode, textNode } from '../../../../lib/vnode.js';
19
19
  import { note, renderDeclaredFieldsTree, widgetFor, } from '../../../../Widget/index.js';
20
- import { FIELDS, } from '../../Form/create.js';
21
20
  const DEFAULT_WIDTH = 58;
22
21
  /** The id the popup's body column is rooted at. */
23
22
  export const CREATE_FORM_REGION = 'create-form';
@@ -34,7 +33,7 @@ const WHOLE_FORM = 'The form options';
34
33
  */
35
34
  const bagName = (field) => field.kind === 'multiselect' ? field.label : `${field.label} options`;
36
35
  /** Render the "New ticket" popup as the tree the kernel frames. */
37
- export function createFormTree(s, opts, chrome = {}) {
36
+ export function createFormTree(fields, s, opts, chrome = {}) {
38
37
  const width = chrome.width ?? DEFAULT_WIDTH;
39
38
  // Keyed by BAG, which is what `from` names; the typed shape is the adapter's
40
39
  // business, and reading it by key is what keeps this loop declaration-driven.
@@ -49,7 +48,8 @@ export function createFormTree(s, opts, chrome = {}) {
49
48
  // one, plus a whole-form failure, collapses into ONE row at the top. It is a
50
49
  // note, not a refusal: a failed OPTION read must not cost the writer the
51
50
  // description they opened the popup to type (D4).
52
- const formLevel = FIELDS.filter((f) => !widgetFor(f).ownsError)
51
+ const formLevel = fields
52
+ .filter((f) => !widgetFor(f).ownsError)
53
53
  .map(reason)
54
54
  .filter((sentence) => sentence !== undefined);
55
55
  if (errors.form !== undefined) {
@@ -63,7 +63,7 @@ export function createFormTree(s, opts, chrome = {}) {
63
63
  // `note` supplies the `⚠` sigil itself, so the message is passed plain —
64
64
  // and it WRAPS, which is what keeps a long server message off the frame's
65
65
  // ellipsis (the same reason the option-load note above it wraps).
66
- ...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(FIELDS, s, opts, {
66
+ ...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(fields, s, opts, {
67
67
  width,
68
68
  errorFor: reason,
69
69
  // A form-level failure means no read succeeded, so an empty bag may not
@@ -69,10 +69,6 @@ const colonValue = (text) => {
69
69
  const index = text.indexOf(':');
70
70
  return index < 0 ? text : text.slice(index + 1);
71
71
  };
72
- const entityLabel = (document, id, type) => {
73
- const row = (document.included ?? []).find((resource) => resource.id === id && resource.type === type);
74
- return (asString(row?.attributes?.label) ?? asString(row?.attributes?.name) ?? id);
75
- };
76
72
  const PRIORITY_LABEL = {
77
73
  '1': 'high',
78
74
  '2': 'medium',
@@ -86,12 +82,6 @@ const lifecycleProperties = (document) => {
86
82
  const priorityKey = String(attributes.priority ?? '').toLowerCase();
87
83
  const extras = [
88
84
  property('priority', PRIORITY_LABEL[priorityKey] ?? ''),
89
- property('team', (() => {
90
- const teamId = relationshipIds(document, 'team')[0];
91
- return teamId === undefined
92
- ? ''
93
- : entityLabel(document, teamId, 'gaia_team--gaia_team');
94
- })()),
95
85
  property('closed', attributes.closed === true ? 'yes' : 'no'),
96
86
  ];
97
87
  const frozenAtRaw = attributes.frozen_at;
@@ -44,8 +44,8 @@ function headerLineNodes(id, content, fg) {
44
44
  }
45
45
  export function ticketHeaderTree(document, identifier, title, lastRow, labels) {
46
46
  // One compact meta line — state · assignee · labels — under the title. The
47
- // full set (created by, team, branch, …) lives in the Overview sidebar, so
48
- // the header stays two lines rather than five. Empty fields drop out.
47
+ // full set (created by, branch, …) lives in the Overview sidebar, so the
48
+ // header stays two lines rather than five. Empty fields drop out.
49
49
  const meta = [
50
50
  headerState(document),
51
51
  headerAssignee(document, labels),
@@ -53,7 +53,6 @@ export function loadTicketDetail(client, ticketId, includes = [
53
53
  'parent_id',
54
54
  'labels',
55
55
  'blocked_by',
56
- 'team',
57
56
  'owner_user_id',
58
57
  'reviewers',
59
58
  'pending_reviews',
@@ -29,7 +29,6 @@ const allIncludes = [
29
29
  'sub_tickets',
30
30
  'referenced_tickets',
31
31
  'runs',
32
- 'team',
33
32
  'owner_user_id',
34
33
  'reviewers',
35
34
  'pending_reviews',
@@ -1,5 +1,5 @@
1
1
  import type { DropshClient } from '../../../types.js';
2
- import { type OptionSources } from '../../../Widget/index.js';
2
+ import { type OptionSource, type OptionSources } from '../../../Widget/index.js';
3
3
  import { type CreateFormOptions } from './create.js';
4
4
  export { type EditFormClients, type EditFormHints, loadEditFormOptions, } from './edit-data.js';
5
5
  export { parentLabel } from './parent-source.js';
@@ -29,6 +29,15 @@ export interface CreateFormHints {
29
29
  * conductors — only what counts as "own" differs, which the caller passes in.
30
30
  */
31
31
  export declare const ownFirst: <T>(rows: T[], isOwn: (row: T) => boolean) => T[];
32
+ /**
33
+ * The `conductors` option source for one (possibly absent) project.
34
+ *
35
+ * Extracted from `createFormSources` (GAIA-664 AC-8) so the screen can call it
36
+ * a SECOND time, scoped to whatever project the operator just picked in the
37
+ * new Project field — the only way the Conductor bag can re-scope without a
38
+ * second, hand-written read living beside this one.
39
+ */
40
+ export declare function conductorOptionSource(client: DropshClient, hints: Pick<CreateFormHints, 'projectId' | 'ownerUserId' | 'ownConductorMachineId'>): OptionSource;
32
41
  /**
33
42
  * The option sources this popup's fields load through, keyed by FIELD id.
34
43
  *
@@ -16,11 +16,12 @@
16
16
  // primes them, re-queries them and merges them for every field alike. The popup's
17
17
  // policy is unchanged and still lives here; only the plumbing left.
18
18
  import { primeSources, } from '../../../Widget/index.js';
19
- import { listConductors } from '../../conductor/Data/conductors.js';
19
+ import { listConductorsForProject } from '../../conductor/Data/conductors.js';
20
20
  import { listTerms } from '../../term/Data/terms.js';
21
21
  import { listUsers } from '../../user/Data/users.js';
22
22
  import { WORKFLOW_OPTIONS, } from './create.js';
23
23
  import { parentOptionSource } from './parent-source.js';
24
+ import { projectOptionSource } from './project-source.js';
24
25
  import { loadReviewerOptions } from './reviewer-options.js';
25
26
  // One back-compat re-export, no implementation: `loadEditFormOptions` lives in
26
27
  // `./edit-form-data.js`, because a module named for the create form owning the EDIT
@@ -48,6 +49,43 @@ const toOption = (row) => ({ id: row.id, label: row.label });
48
49
  * type's vocabularies a given widget wants is the widget's business.
49
50
  */
50
51
  const LABEL_VOCABULARY = 'gaia_labels';
52
+ /**
53
+ * The `conductors` option source for one (possibly absent) project.
54
+ *
55
+ * Extracted from `createFormSources` (GAIA-664 AC-8) so the screen can call it
56
+ * a SECOND time, scoped to whatever project the operator just picked in the
57
+ * new Project field — the only way the Conductor bag can re-scope without a
58
+ * second, hand-written read living beside this one.
59
+ */
60
+ export function conductorOptionSource(client, hints) {
61
+ const { projectId, ownerUserId, ownConductorMachineId } = hints;
62
+ return {
63
+ bag: 'conductors',
64
+ async load() {
65
+ // GAIA-664 review F4: a new ticket's project scopes the conductor
66
+ // candidates the same way the Web dialog's selection handler does —
67
+ // including offering NONE without a resolved project, since the
68
+ // server now rejects a conductor assignment on a projectless ticket
69
+ // the same way it rejects a cross-project one.
70
+ if (projectId === undefined) {
71
+ return { options: [] };
72
+ }
73
+ const outcome = await listConductorsForProject(client, projectId);
74
+ const assignable = outcome.rows.filter((c) => c.active);
75
+ // Two stable partitions, strongest LAST (GAIA-460 D8): everything I own
76
+ // rises above everyone else's, then this checkout's record rises above
77
+ // the rest of mine. Applied in the other order the weaker rule would
78
+ // scatter what the stronger one just gathered.
79
+ const byOwner = ownerUserId
80
+ ? ownFirst(assignable, (c) => c.ownerUserId === ownerUserId)
81
+ : assignable;
82
+ const rows = ownConductorMachineId
83
+ ? ownFirst(byOwner, (c) => c.machineId === ownConductorMachineId)
84
+ : byOwner;
85
+ return { options: rows.map(toOption), error: outcome.error };
86
+ },
87
+ };
88
+ }
51
89
  /**
52
90
  * The option sources this popup's fields load through, keyed by FIELD id.
53
91
  *
@@ -91,24 +129,11 @@ export function createFormSources(client, hints = {}) {
91
129
  return loadReviewerOptions(client, workspaceId);
92
130
  },
93
131
  },
94
- conductor: {
95
- bag: 'conductors',
96
- async load() {
97
- const outcome = await listConductors(client);
98
- const assignable = outcome.rows.filter((c) => c.active);
99
- // Two stable partitions, strongest LAST (GAIA-460 D8): everything I own
100
- // rises above everyone else's, then this checkout's record rises above
101
- // the rest of mine. Applied in the other order the weaker rule would
102
- // scatter what the stronger one just gathered.
103
- const byOwner = ownerUserId
104
- ? ownFirst(assignable, (c) => c.ownerUserId === ownerUserId)
105
- : assignable;
106
- const rows = ownConductorMachineId
107
- ? ownFirst(byOwner, (c) => c.machineId === ownConductorMachineId)
108
- : byOwner;
109
- return { options: rows.map(toOption), error: outcome.error };
110
- },
111
- },
132
+ conductor: conductorOptionSource(client, {
133
+ projectId,
134
+ ownerUserId,
135
+ ownConductorMachineId,
136
+ }),
112
137
  labels: {
113
138
  bag: 'labels',
114
139
  async load() {
@@ -125,6 +150,12 @@ export function createFormSources(client, hints = {}) {
125
150
  // error channel the parent-filter picker composes.
126
151
  sources.parent = parentOptionSource(client, projectId);
127
152
  }
153
+ else {
154
+ // GAIA-664 AC-8: no project in scope ⇒ the popup offers a Project field
155
+ // instead of refusing outright, over the SAME source the project-filter
156
+ // picker searches with (`projectOptionSource`) — never a second read.
157
+ sources.project = projectOptionSource(client);
158
+ }
128
159
  return sources;
129
160
  }
130
161
  /**
@@ -17,6 +17,9 @@ export interface CreateOptionErrors {
17
17
  labels?: string | undefined;
18
18
  parents?: string | undefined;
19
19
  workflows?: string | undefined;
20
+ /** GAIA-664 AC-8: the project picker's own search, shown only while the
21
+ * popup has no project in scope — see {@link CreateFormOptions.projects}. */
22
+ projects?: string | undefined;
20
23
  /**
21
24
  * The whole load threw, rather than one bag failing. Until GAIA-257 the kernel
22
25
  * answered that by silently substituting four empty bags (F2a) — a whole-form
@@ -51,6 +54,13 @@ export interface CreateFormOptions {
51
54
  * the first thing, which is why the key is optional rather than defaulted.
52
55
  */
53
56
  parents?: ParentOption[] | undefined;
57
+ /**
58
+ * Candidate projects, searched by name — the SAME source the project-filter
59
+ * picker composes (`projectOptionSource`). Only declared as a source while
60
+ * the popup has no project in scope (GAIA-664 AC-8): with one, the operator
61
+ * is already working inside it and the field does not exist.
62
+ */
63
+ projects?: Option[] | undefined;
54
64
  /** Present only when at least one read failed; see `CreateOptionErrors`. */
55
65
  errors?: CreateOptionErrors | undefined;
56
66
  }
@@ -69,9 +79,19 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
69
79
  */
70
80
  export declare const AGENT_ACTION_ID = "submit";
71
81
  export declare const DIRECT_ACTION_ID = "direct";
72
- export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
82
+ export type CreateFieldId = 'project' | 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
73
83
  /** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
74
84
  export interface CreateValues {
85
+ /**
86
+ * The PICKED project's uuid, or null — GAIA-664 AC-8. Only a field on the
87
+ * form built WITHOUT a project in scope ({@link CREATE_FORM_WITH_PROJECT});
88
+ * on the ordinary form (a project already in scope) nothing ever sets it, so
89
+ * it reads as `undefined` at runtime despite the type — the same shape the
90
+ * other two-key autocomplete, `parent`, already has when its source is
91
+ * absent. `projectQuery` holds what was typed.
92
+ */
93
+ project: string | null;
94
+ projectQuery: string;
75
95
  /**
76
96
  * The ticket's title. `gaia_ticket.title` is `setRequired(TRUE)` server-side,
77
97
  * so a direct create without one is rejected — this was never optional
@@ -117,7 +137,8 @@ export interface CreateFormPayload {
117
137
  /** The option with `id`, or null — used by both forms' `toObject`. */
118
138
  export declare const optionFor: (list: Option[], id: string | null) => Option | null;
119
139
  /**
120
- * The declaration. No field carries a function. Exactly ONE select is
140
+ * The declaration used whenever a project is already in scope (unchanged,
141
+ * GAIA-664 AC-8). No field carries a function. Exactly ONE select is
121
142
  * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
122
143
  * options only, exactly as before GAIA-233.
123
144
  */
@@ -129,8 +150,29 @@ export declare const CREATE_FORM: {
129
150
  apply(s: FormState<CreateValues>, options: CreateFormOptions, intent: import("../../../Form/form.js").Intent): FormState<CreateValues>;
130
151
  toObject: (s: FormState<CreateValues>, options: CreateFormOptions) => CreateFormPayload;
131
152
  };
132
- /** Field order in the popup (top to bottom). */
153
+ /**
154
+ * The declaration used when the popup opens with NO project in scope
155
+ * (GAIA-664 AC-8, the human decision replacing the old refusal-only form):
156
+ * one extra `autocomplete` field, ahead of everything else, over the SAME
157
+ * `projectOptionSource` the project-filter picker searches with — never a
158
+ * second project read. Picking a row here is what the screen reads to scope
159
+ * the Conductor bag and to carry a project into both submit paths; `toObject`
160
+ * needs none of it; the id travels through `CreateValues.project` instead,
161
+ * the same way the screen already reads `context.project` for the other form.
162
+ */
163
+ export declare const CREATE_FORM_WITH_PROJECT: {
164
+ fields: FieldDecl[];
165
+ activeField: (s: FormState<CreateValues>) => FieldDecl;
166
+ move: (s: FormState<CreateValues>, d: number) => FormState<CreateValues>;
167
+ initial(options: CreateFormOptions, prefill?: Partial<CreateValues>): FormState<CreateValues>;
168
+ apply(s: FormState<CreateValues>, options: CreateFormOptions, intent: import("../../../Form/form.js").Intent): FormState<CreateValues>;
169
+ toObject: (s: FormState<CreateValues>, options: CreateFormOptions) => CreateFormPayload;
170
+ };
171
+ /** Field order in the popup (top to bottom), a project already in scope. */
133
172
  export declare const FIELDS: FieldDef<CreateFieldId>[];
173
+ /** Field order with NO project in scope (GAIA-664 AC-8) — `FIELDS` plus the
174
+ * Project picker, ahead of everything else. */
175
+ export declare const FIELDS_WITH_PROJECT: FieldDef<CreateFieldId>[];
134
176
  export type CreateFormState = FormState<CreateValues>;
135
177
  /**
136
178
  * The values the popup carries from one open to the next (GAIA-314 D2).
@@ -5,6 +5,7 @@
5
5
  // is repeated here.
6
6
  import { cycleValue, declareForm, } from '../../../Form/index.js';
7
7
  import { PARENT_PAGE_SIZE } from '../Data/parents.js';
8
+ import { PROJECT_PAGE_SIZE } from '../Data/projects-search.js';
8
9
  // `cycleValue` is re-exported because GAIA-233's contract cases pin it against
9
10
  // an arbitrary option list, which no declared form can supply.
10
11
  //
@@ -48,62 +49,95 @@ const parentFor = (list, id) => {
48
49
  : null;
49
50
  };
50
51
  /**
51
- * The declaration. No field carries a function. Exactly ONE select is
52
+ * The shared field list (GAIA-664 AC-8): every field BUT the project picker,
53
+ * which only one of the two declarations below carries. Factored out so the
54
+ * two forms cannot drift apart on the fields neither decision touches — only
55
+ * `declareForm`'s one `fields` callback may differ between them.
56
+ */
57
+ const sharedFields = (f) => [
58
+ // First, because it is the one field the server REQUIRES (D1). Both submit
59
+ // paths read it: the direct write sends it, the agent adopts it from the
60
+ // temp JSON spec.
61
+ f.text('title', 'Title'),
62
+ f.text('description', 'Description'),
63
+ // Between the description and the assignee on purpose: a parent is chosen
64
+ // WITH what the ticket is about, before who works on it (GAIA-257 D5).
65
+ f.autocomplete('parent', 'Parent', {
66
+ from: 'parents',
67
+ query: 'parentQuery',
68
+ // The two things an ENTITY-AGNOSTIC widget cannot know, as plain data on
69
+ // the declaration (GAIA-257 re-entry AC-15 keeps a field printable, so
70
+ // neither may be a function). `absentNote` is what an ABSENT bag means
71
+ // HERE — the popup opened from the dashboard, where `n` is global and no
72
+ // project is in scope (D8) — as against an empty array, which would mean
73
+ // "this project was searched and nothing matched". `pageSize` is what the
74
+ // bag is filled from, so the suggestion list can say a full page may be a
75
+ // truncated answer even when the local filter narrows it (R3 / AC-17).
76
+ absentNote: '(no project context — open a project to pick a parent)',
77
+ pageSize: PARENT_PAGE_SIZE,
78
+ }),
79
+ f.select('assignee', 'Assignee', { from: 'users' }),
80
+ f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
81
+ // Clearable (D2): a SET conductor means the ticket is claimed at the next
82
+ // poll and a qualification run starts; a cleared one means it waits for a
83
+ // human. The field is clearable precisely so that choice is the operator's
84
+ // and not implicit. The preselection survives — `initial()` fills a select
85
+ // from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
86
+ // `create-data.ts` puts the machine's own conductor there.
87
+ f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
88
+ f.multi('labels', 'Labels', { from: 'labels' }),
89
+ f.select('workflow', 'Workflow', { from: 'workflows' }),
90
+ // Agent row FIRST and keeping the id `'submit'` (D7): nothing that exists
91
+ // today changes its meaning or its position.
92
+ f.action('Create with agent', AGENT_ACTION_ID),
93
+ f.action('Create directly', DIRECT_ACTION_ID),
94
+ ];
95
+ const toCreatePayload = (v, o) => ({
96
+ title: v.title,
97
+ description: v.description,
98
+ workflow: optionFor(o.workflows, v.workflow),
99
+ assignee: optionFor(o.users, v.assignee),
100
+ reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
101
+ conductor: optionFor(o.conductors, v.conductor),
102
+ labels: o.labels.filter((x) => v.labels.has(x.id)),
103
+ parent: parentFor(o.parents ?? [], v.parent),
104
+ });
105
+ /**
106
+ * The declaration used whenever a project is already in scope (unchanged,
107
+ * GAIA-664 AC-8). No field carries a function. Exactly ONE select is
52
108
  * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
53
109
  * options only, exactly as before GAIA-233.
54
110
  */
55
111
  export const CREATE_FORM = declareForm({
112
+ fields: sharedFields,
113
+ toObject: toCreatePayload,
114
+ });
115
+ /**
116
+ * The declaration used when the popup opens with NO project in scope
117
+ * (GAIA-664 AC-8, the human decision replacing the old refusal-only form):
118
+ * one extra `autocomplete` field, ahead of everything else, over the SAME
119
+ * `projectOptionSource` the project-filter picker searches with — never a
120
+ * second project read. Picking a row here is what the screen reads to scope
121
+ * the Conductor bag and to carry a project into both submit paths; `toObject`
122
+ * needs none of it; the id travels through `CreateValues.project` instead,
123
+ * the same way the screen already reads `context.project` for the other form.
124
+ */
125
+ export const CREATE_FORM_WITH_PROJECT = declareForm({
56
126
  fields: (f) => [
57
- // First, because it is the one field the server REQUIRES (D1). Both submit
58
- // paths read it: the direct write sends it, the agent adopts it from the
59
- // temp JSON spec.
60
- f.text('title', 'Title'),
61
- f.text('description', 'Description'),
62
- // Between the description and the assignee on purpose: a parent is chosen
63
- // WITH what the ticket is about, before who works on it (GAIA-257 D5).
64
- f.autocomplete('parent', 'Parent', {
65
- from: 'parents',
66
- query: 'parentQuery',
67
- // The two things an ENTITY-AGNOSTIC widget cannot know, as plain data on
68
- // the declaration (GAIA-257 re-entry AC-15 keeps a field printable, so
69
- // neither may be a function). `absentNote` is what an ABSENT bag means
70
- // HERE — the popup opened from the dashboard, where `n` is global and no
71
- // project is in scope (D8) — as against an empty array, which would mean
72
- // "this project was searched and nothing matched". `pageSize` is what the
73
- // bag is filled from, so the suggestion list can say a full page may be a
74
- // truncated answer even when the local filter narrows it (R3 / AC-17).
75
- absentNote: '(no project context — open a project to pick a parent)',
76
- pageSize: PARENT_PAGE_SIZE,
127
+ f.autocomplete('project', 'Project', {
128
+ from: 'projects',
129
+ query: 'projectQuery',
130
+ pageSize: PROJECT_PAGE_SIZE,
77
131
  }),
78
- f.select('assignee', 'Assignee', { from: 'users' }),
79
- f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
80
- // Clearable (D2): a SET conductor means the ticket is claimed at the next
81
- // poll and a qualification run starts; a cleared one means it waits for a
82
- // human. The field is clearable precisely so that choice is the operator's
83
- // and not implicit. The preselection survives — `initial()` fills a select
84
- // from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
85
- // `create-data.ts` puts the machine's own conductor there.
86
- f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
87
- f.multi('labels', 'Labels', { from: 'labels' }),
88
- f.select('workflow', 'Workflow', { from: 'workflows' }),
89
- // Agent row FIRST and keeping the id `'submit'` (D7): nothing that exists
90
- // today changes its meaning or its position.
91
- f.action('Create with agent', AGENT_ACTION_ID),
92
- f.action('Create directly', DIRECT_ACTION_ID),
132
+ ...sharedFields(f),
93
133
  ],
94
- toObject: (v, o) => ({
95
- title: v.title,
96
- description: v.description,
97
- workflow: optionFor(o.workflows, v.workflow),
98
- assignee: optionFor(o.users, v.assignee),
99
- reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
100
- conductor: optionFor(o.conductors, v.conductor),
101
- labels: o.labels.filter((x) => v.labels.has(x.id)),
102
- parent: parentFor(o.parents ?? [], v.parent),
103
- }),
134
+ toObject: toCreatePayload,
104
135
  });
105
- /** Field order in the popup (top to bottom). */
136
+ /** Field order in the popup (top to bottom), a project already in scope. */
106
137
  export const FIELDS = CREATE_FORM.fields;
138
+ /** Field order with NO project in scope (GAIA-664 AC-8) — `FIELDS` plus the
139
+ * Project picker, ahead of everything else. */
140
+ export const FIELDS_WITH_PROJECT = CREATE_FORM_WITH_PROJECT.fields;
107
141
  /** Snapshot the routing values of a form that is about to be replaced. */
108
142
  export const rememberedValues = (s) => ({
109
143
  parent: s.parent,
@@ -27,6 +27,17 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
27
27
  private context;
28
28
  private sources;
29
29
  private optionLoader;
30
+ /**
31
+ * Which declared form is standing (GAIA-664 AC-8): the ordinary one when a
32
+ * project is already in scope (unchanged), or the one with the extra
33
+ * Project field when it is not. Set once per `load()` — a scope change goes
34
+ * through `reset()`, which drops the whole screen, not just this flag.
35
+ */
36
+ private hasProjectContext;
37
+ /** The hints `load()` resolved, MINUS `projectId` — kept so a project picked
38
+ * in the new field can rebuild the Conductor source with the rest intact
39
+ * (GAIA-664 AC-8). */
40
+ private hints;
30
41
  /** The last refusal, drawn inside the popup (GAIA-309 D6). */
31
42
  private message;
32
43
  /** In-flight guard against a second POST from a second keypress. */
@@ -39,6 +50,10 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
39
50
  */
40
51
  private readonly ownerIdentity;
41
52
  get options(): CreateFormOptions | undefined;
53
+ /** The declared form standing right now (GAIA-664 AC-8). */
54
+ private declaration;
55
+ /** `declaration()`'s own field list — what the component walks to render. */
56
+ private fieldList;
42
57
  protected load(context: ScreenContext): Promise<CreateFormOptions>;
43
58
  /**
44
59
  * Reopening the popup starts a NEW ticket (GAIA-314 D1/D3).
@@ -57,6 +72,7 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
57
72
  */
58
73
  enter(context: ScreenContext): void;
59
74
  protected didLoad(options: CreateFormOptions): void;
75
+ private startOptionLoader;
60
76
  protected renderLoading(): string[];
61
77
  /**
62
78
  * GAIA-340 D1/D3 — the popup's body as the tree the widget layer builds.
@@ -73,6 +89,14 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
73
89
  protected renderReady(options: CreateFormOptions): VNode;
74
90
  takeAction(): ScreenAction | undefined;
75
91
  handleInput(input: string, context: ScreenContext): boolean;
92
+ /**
93
+ * Re-scope the project-dependent bags to the project just picked in the
94
+ * Project field (GAIA-664 AC-8): Conductor through the same source
95
+ * `createFormSources` declares, Parent by swapping in that project's parent
96
+ * source (the option loader reads `this.sources` at query time, so later
97
+ * typing searches the new project). No project: no conductor, no parent.
98
+ */
99
+ private rescopeToProject;
76
100
  reset(): void;
77
101
  protected modalChrome(context: ScreenContext): ModalChrome;
78
102
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
@@ -11,9 +11,10 @@ import { OwnerIdentity } from '../../conductor/Data/own-rows.js';
11
11
  import { resolveWhoCandidate } from '../../user/Data/users.js';
12
12
  import { CREATE_FORM_REGION, createFormTree, } from '../Component/create-form/index.js';
13
13
  import { buildTicketCreateBody, createTicket } from '../Data/write.js';
14
- import { AGENT_ACTION_ID, CREATE_FORM, DIRECT_ACTION_ID, initialForm, rememberedValues, } from '../Form/create.js';
15
- import { composeCreateFormOptions, createFormSources, } from '../Form/create-data.js';
14
+ import { AGENT_ACTION_ID, CREATE_FORM, CREATE_FORM_WITH_PROJECT, DIRECT_ACTION_ID, FIELDS, FIELDS_WITH_PROJECT, optionFor, rememberedValues, } from '../Form/create.js';
15
+ import { composeCreateFormOptions, conductorOptionSource, createFormSources, } from '../Form/create-data.js';
16
16
  import { keyToIntent } from '../Form/keys.js';
17
+ import { parentOptionSource } from '../Form/parent-source.js';
17
18
  // The key hints, on TWO rows (GAIA-309 D7 / AC-1).
18
19
  //
19
20
  // Not a style choice. The popup is a fixed overlay — the kernel draws it at
@@ -54,6 +55,17 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
54
55
  context;
55
56
  sources = {};
56
57
  optionLoader;
58
+ /**
59
+ * Which declared form is standing (GAIA-664 AC-8): the ordinary one when a
60
+ * project is already in scope (unchanged), or the one with the extra
61
+ * Project field when it is not. Set once per `load()` — a scope change goes
62
+ * through `reset()`, which drops the whole screen, not just this flag.
63
+ */
64
+ hasProjectContext = false;
65
+ /** The hints `load()` resolved, MINUS `projectId` — kept so a project picked
66
+ * in the new field can rebuild the Conductor source with the rest intact
67
+ * (GAIA-664 AC-8). */
68
+ hints = {};
57
69
  /** The last refusal, drawn inside the popup (GAIA-309 D6). */
58
70
  message = '';
59
71
  /** In-flight guard against a second POST from a second keypress. */
@@ -68,6 +80,14 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
68
80
  get options() {
69
81
  return this.readyValue;
70
82
  }
83
+ /** The declared form standing right now (GAIA-664 AC-8). */
84
+ declaration() {
85
+ return this.hasProjectContext ? CREATE_FORM : CREATE_FORM_WITH_PROJECT;
86
+ }
87
+ /** `declaration()`'s own field list — what the component walks to render. */
88
+ fieldList() {
89
+ return this.hasProjectContext ? FIELDS : FIELDS_WITH_PROJECT;
90
+ }
71
91
  async load(context) {
72
92
  this.context = context;
73
93
  if (!context.services)
@@ -77,7 +97,8 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
77
97
  // once per screen lifetime through `OwnerIdentity` rather than threaded
78
98
  // straight through. Fails open: `unavailable` omits the hint entirely.
79
99
  const ownerOutcome = await this.ownerIdentity.resolve(context.services.client, context.services.currentUser);
80
- const sources = createFormSources(context.services.client, {
100
+ this.hasProjectContext = context.project !== undefined;
101
+ this.hints = {
81
102
  ...(context.services.currentUser === undefined
82
103
  ? {}
83
104
  : { currentUser: context.services.currentUser }),
@@ -87,12 +108,15 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
87
108
  ...(context.services.ownConductorMachineId === undefined
88
109
  ? {}
89
110
  : { ownConductorMachineId: context.services.ownConductorMachineId }),
90
- ...(context.project === undefined
91
- ? {}
92
- : { projectId: context.project.id }),
93
111
  ...(context.services.workspaceId === undefined
94
112
  ? {}
95
113
  : { workspaceId: context.services.workspaceId }),
114
+ };
115
+ const sources = createFormSources(context.services.client, {
116
+ ...this.hints,
117
+ ...(context.project === undefined
118
+ ? {}
119
+ : { projectId: context.project.id }),
96
120
  });
97
121
  this.sources = sources;
98
122
  return composeCreateFormOptions(sources).catch((caught) => ({
@@ -130,7 +154,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
130
154
  // it through the very same rule.
131
155
  const options = this.readyValue;
132
156
  if (options) {
133
- this.form = initialForm(options, this.remembered ?? {});
157
+ this.form = this.declaration().initial(options, this.remembered ?? {});
134
158
  // `super.enter`'s `ready` branch already painted — from the form we just
135
159
  // replaced. The kernel draws SYNCHRONOUSLY inside `requestDraw`, so that
136
160
  // paint is the only one this navigation issues, and without a second one
@@ -142,7 +166,11 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
142
166
  }
143
167
  }
144
168
  didLoad(options) {
145
- this.form = initialForm(options, this.remembered ?? {});
169
+ this.form = this.declaration().initial(options, this.remembered ?? {});
170
+ this.startOptionLoader();
171
+ }
172
+ startOptionLoader() {
173
+ this.optionLoader?.cancel();
146
174
  this.optionLoader = createOptionLoader({
147
175
  sources: this.sources,
148
176
  // Absent unless the caller injected one: `createOptionLoader` owns the
@@ -179,7 +207,9 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
179
207
  // the standing tree rather than build a second one beside it.
180
208
  if (!this.form)
181
209
  return columnNode(CREATE_FORM_REGION);
182
- return createFormTree(this.form, options, { message: this.message });
210
+ return createFormTree(this.fieldList(), this.form, options, {
211
+ message: this.message,
212
+ });
183
213
  }
184
214
  takeAction() {
185
215
  const action = this.action;
@@ -192,14 +222,15 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
192
222
  const options = this.readyValue;
193
223
  if (!options || !this.form)
194
224
  return false;
195
- const field = CREATE_FORM.activeField(this.form);
225
+ const declaration = this.declaration();
226
+ const field = declaration.activeField(this.form);
196
227
  // GAIA-309 D7 — Ctrl+D is handled HERE rather than in `Form/keys.ts`. The
197
228
  // kernel's one `submit` intent cannot say WHICH of two actions was meant,
198
229
  // and a generic key module has no business knowing that a form has an
199
230
  // "agent" action and a "direct" one. So the second binding belongs to the
200
231
  // form that has two, and the kernel's Intent union is untouched.
201
232
  if (input === 'ctrl+d') {
202
- const payload = CREATE_FORM.toObject(this.form, options);
233
+ const payload = declaration.toObject(this.form, options);
203
234
  void this.submitDirect(payload, context);
204
235
  context.requestDraw();
205
236
  return true;
@@ -208,7 +239,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
208
239
  // suggestion-browsing to single-field forms; this form has several, so
209
240
  // ↑/↓ keep moving between fields here (AC-5).
210
241
  const intent = keyToIntent(input, field, {
211
- fieldCount: CREATE_FORM.fields.length,
242
+ fieldCount: declaration.fields.length,
212
243
  });
213
244
  if (!intent)
214
245
  return true;
@@ -216,7 +247,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
216
247
  this.action = { type: 'back' };
217
248
  }
218
249
  else if (intent.kind === 'submit') {
219
- const payload = CREATE_FORM.toObject(this.form, options);
250
+ const payload = declaration.toObject(this.form, options);
220
251
  // Ctrl+S PRESSES THE ROW YOU ARE STANDING ON; from any other field it
221
252
  // means the agent, exactly as it did before this ticket (AC-6). Enter on
222
253
  // an action row arrives here as the same `submit` intent, so both keys
@@ -234,7 +265,29 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
234
265
  // operator types the missing title and is still told the title is
235
266
  // missing. Any field change retires it.
236
267
  this.message = '';
237
- this.form = CREATE_FORM.apply(this.form, options, intent);
268
+ // GAIA-664 AC-8: a NEW pick (or a clear) on the Project field re-scopes
269
+ // the Conductor bag and drops any conductor picked under the PREVIOUS
270
+ // project — the same staleness `parent`'s project already forbids,
271
+ // applied to the field that now decides it instead of receiving it.
272
+ const previousProject = field.id === 'project'
273
+ ? this.form.project
274
+ : null;
275
+ this.form = declaration.apply(this.form, options, intent);
276
+ // Any change counts, typing included: editing the query drops the
277
+ // pick, and a conductor of the dropped project must go with it.
278
+ if (field.id === 'project') {
279
+ const nextProject = this.form
280
+ .project;
281
+ if (nextProject !== previousProject) {
282
+ this.form = {
283
+ ...this.form,
284
+ conductor: null,
285
+ parent: null,
286
+ parentQuery: '',
287
+ };
288
+ void this.rescopeToProject(nextProject, context);
289
+ }
290
+ }
238
291
  if (field.query !== undefined &&
239
292
  (intent.kind === 'insert' || intent.kind === 'backspace')) {
240
293
  const query = this.form[field.query];
@@ -244,6 +297,57 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
244
297
  context.requestDraw();
245
298
  return true;
246
299
  }
300
+ /**
301
+ * Re-scope the project-dependent bags to the project just picked in the
302
+ * Project field (GAIA-664 AC-8): Conductor through the same source
303
+ * `createFormSources` declares, Parent by swapping in that project's parent
304
+ * source (the option loader reads `this.sources` at query time, so later
305
+ * typing searches the new project). No project: no conductor, no parent.
306
+ */
307
+ async rescopeToProject(projectId, context) {
308
+ const client = context.services?.client;
309
+ if (!client)
310
+ return;
311
+ const { parent: _previous, ...rest } = this.sources;
312
+ this.sources =
313
+ projectId === null
314
+ ? rest
315
+ : { ...rest, parent: parentOptionSource(client, projectId) };
316
+ this.startOptionLoader();
317
+ const conductorSource = conductorOptionSource(client, {
318
+ ...this.hints,
319
+ ...(projectId === null ? {} : { projectId }),
320
+ });
321
+ const [conductors, parents] = await Promise.all([
322
+ conductorSource.load(''),
323
+ this.sources.parent?.load(''),
324
+ ]);
325
+ const current = this.readyValue;
326
+ if (!current || !context.isActive())
327
+ return;
328
+ // A later pick supersedes this load: a slow answer for the previous
329
+ // project must not replace the current project's candidates.
330
+ const picked = this.form
331
+ ?.project ?? null;
332
+ if (picked !== projectId)
333
+ return;
334
+ // Errors merged like the option loader's own merge: a failed read shows
335
+ // its error instead of reading as "this project has none".
336
+ const errors = { ...current.errors };
337
+ delete errors.conductors;
338
+ delete errors.parents;
339
+ if (conductors.error !== undefined)
340
+ errors.conductors = conductors.error;
341
+ if (parents?.error !== undefined)
342
+ errors.parents = parents.error;
343
+ this.replaceReady({
344
+ ...current,
345
+ conductors: conductors.options,
346
+ parents: parents?.options,
347
+ errors: Object.keys(errors).length > 0 ? errors : undefined,
348
+ });
349
+ context.requestDraw();
350
+ }
247
351
  reset() {
248
352
  this.form = undefined;
249
353
  this.remembered = undefined;
@@ -254,6 +358,8 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
254
358
  this.optionLoader?.cancel();
255
359
  this.optionLoader = undefined;
256
360
  this.sources = {};
361
+ this.hasProjectContext = false;
362
+ this.hints = {};
257
363
  this.ownerIdentity.reset();
258
364
  super.reset();
259
365
  }
@@ -278,7 +384,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
278
384
  }
279
385
  fieldActions() {
280
386
  return this.form &&
281
- CREATE_FORM.activeField(this.form).kind === 'autocomplete'
387
+ this.declaration().activeField(this.form).kind === 'autocomplete'
282
388
  ? [
283
389
  actionItem('create-field', '↑↓', 'field'),
284
390
  actionItem('create-suggest', '←→', 'suggestion'),
@@ -312,12 +418,17 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
312
418
  // D8 — a project is a PRECONDITION, not a fallback. Without `project_id`
313
419
  // the server writes a ticket that draws no number and gets no GAIA-nnn
314
420
  // identifier (`GaiaTicket::allocateNumber()` returns early), which is worse
315
- // than not writing one. The agent row is unaffected: it resolves the
316
- // project itself.
317
- const projectId = context.project?.id;
421
+ // than not writing one. GAIA-664 AC-8: with no project already in scope the
422
+ // popup shows a Project field instead of refusing outright, so the
423
+ // precondition is now "a project was picked", not "one happened to be in
424
+ // scope" — `context.project` when there was one, the form's own pick when
425
+ // there was not.
426
+ const projectId = context.project?.id ??
427
+ this.form
428
+ ?.project ??
429
+ undefined;
318
430
  if (!projectId) {
319
- this.message =
320
- 'no project in scope — open a project to create directly, or hand it to the agent';
431
+ this.message = 'no project selected — pick one above to create directly';
321
432
  context.requestDraw();
322
433
  return;
323
434
  }
@@ -377,7 +488,24 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
377
488
  };
378
489
  return;
379
490
  }
380
- const project = context.project?.slug ?? context.options?.project ?? 'gaia';
491
+ // GAIA-664 review F4: a guessed project was a fake resolution, not a real
492
+ // one — the literal `gaia` fallback sent the agent into whatever project
493
+ // happened to carry that slug. GAIA-664 AC-8: with no project already in
494
+ // scope the popup offers a Project field instead, and a pick there is as
495
+ // real a resolution as `context.project` — checked before the legacy
496
+ // `context.options?.project` default, since an explicit pick says more
497
+ // than an ambient one.
498
+ const formProjectId = this.form?.project;
499
+ const pickedProject = formProjectId
500
+ ? optionFor(this.readyValue?.projects ?? [], formProjectId)
501
+ : null;
502
+ const project = context.project?.slug ?? pickedProject?.label ?? context.options?.project;
503
+ if (!project) {
504
+ this.message =
505
+ 'no project selected — pick one above to hand this to the agent';
506
+ context.requestDraw();
507
+ return;
508
+ }
381
509
  let specPath;
382
510
  try {
383
511
  specPath = join(tmpdir(), `gaia-new-ticket-${Date.now()}.json`);
@@ -10,10 +10,9 @@ const INCLUDES = [
10
10
  'conductor_id',
11
11
  'assignee_user_id',
12
12
  'comments',
13
- // The Overview sidebar resolves references to names, not raw uuids: the team,
14
- // the owner (Created by), and the sub-/referenced tickets in the Related tab.
13
+ // The Overview sidebar resolves references to names, not raw uuids: the
14
+ // owner (Created by), and the sub-/referenced tickets in the Related tab.
15
15
  // parent_id and blocked_by are already above.
16
- 'team',
17
16
  'owner_user_id',
18
17
  'sub_tickets',
19
18
  'referenced_tickets',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-gaia-ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.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,7 +29,7 @@
29
29
  "zustand": "^5.0.15"
30
30
  },
31
31
  "peerDependencies": {
32
- "@gaia-ai/core": "^0.19.0",
33
- "@gaia-ai/message": "^0.19.0"
32
+ "@gaia-ai/core": "^0.20.0",
33
+ "@gaia-ai/message": "^0.20.0"
34
34
  }
35
35
  }