@cat-factory/app 0.300.0 → 0.300.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 (36) hide show
  1. package/README.md +21 -0
  2. package/app/components/assistant/AssistantModal.logic.spec.ts +129 -1
  3. package/app/components/assistant/AssistantModal.logic.ts +83 -1
  4. package/app/components/assistant/AssistantModal.vue +124 -17
  5. package/app/components/board/AddTaskModal.vue +1 -2
  6. package/app/components/board/CreateInitiativeModal.vue +2 -3
  7. package/app/components/board/RecurringPipelineModal.vue +1 -2
  8. package/app/components/documents/DocumentSourceConnectModal.vue +2 -2
  9. package/app/components/documents/StartFromDesignModal.vue +1 -2
  10. package/app/components/layout/CommandBar.vue +1 -2
  11. package/app/components/outcome/OutcomeSummaryWindow.vue +1 -0
  12. package/app/components/pipeline/PipelineBuilder.vue +6 -6
  13. package/app/components/providers/PersonalCredentialModal.vue +3 -3
  14. package/app/components/tasks/BugHuntModal.vue +1 -2
  15. package/app/components/tasks/TaskImportModal.vue +5 -7
  16. package/app/components/tasks/TaskSourceConnectModal.vue +2 -2
  17. package/app/composables/api/assistant.ts +5 -3
  18. package/app/composables/useArtifactBlobs.ts +2 -1
  19. package/app/composables/useModalOpen.spec.ts +51 -0
  20. package/app/composables/useModalOpen.ts +31 -0
  21. package/app/stores/assistant.spec.ts +183 -0
  22. package/app/stores/assistant.ts +83 -7
  23. package/app/types/domain.ts +4 -0
  24. package/app/types/load-state.ts +18 -0
  25. package/app/utils/catalog.ts +21 -0
  26. package/i18n/locales/de.json +7 -2
  27. package/i18n/locales/en.json +10 -2
  28. package/i18n/locales/es.json +7 -2
  29. package/i18n/locales/fr.json +7 -2
  30. package/i18n/locales/he.json +7 -2
  31. package/i18n/locales/it.json +7 -2
  32. package/i18n/locales/ja.json +7 -2
  33. package/i18n/locales/pl.json +7 -2
  34. package/i18n/locales/tr.json +7 -2
  35. package/i18n/locales/uk.json +7 -2
  36. package/package.json +2 -2
package/README.md CHANGED
@@ -263,6 +263,27 @@ The still-open remainder is the INLINE family: `error.value = e.message` rendere
263
263
  surface rather than a toast, and are tracked as G4 in
264
264
  [`error-message-coverage.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/error-message-coverage.md).
265
265
 
266
+ ### A panel that seeds state on open uses `onModalOpen`, never a bare `watch(open)`
267
+
268
+ **Work done on the way IN to a modal goes through `onModalOpen(open, fn)`
269
+ (`composables/useModalOpen.ts`).** A hand-written `watch(open, (isOpen) => { if (isOpen) … })` is
270
+ wrong in this SPA and reads as right.
271
+
272
+ `pages/index.vue` mounts most panels only WHILE their flag is set
273
+ (`<AssistantModal v-if="ui.assistantOpen" />`), so `open` is already `true` when the component sets
274
+ up and a change-only watcher never fires at all. Whatever the body does never happens: the picker
275
+ keeps its unselected default, the read that fills a catalog is never issued, and the panel opens
276
+ with a confirm button that cannot be pressed. Nothing throws and nothing warns, which is why four
277
+ modals carried it at once and the assistant shipped with a Run button that could never submit.
278
+
279
+ The helper is `{ immediate: true }` plus the open check, and it is correct for an always-mounted
280
+ panel too: that one passes a `false` on the first run and the body is skipped. So nobody has to know
281
+ which kind of panel they are writing, which is the point of it being shared rather than a flag
282
+ remembered per site.
283
+
284
+ A watcher acting on the CLOSE edge (emitting `close`, revoking object URLs) stays a plain `watch`:
285
+ it has nothing to do at mount, and running it there would announce a close that never happened.
286
+
266
287
  ### Type a chip map with `BadgeColor`, never `string`
267
288
 
268
289
  A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
@@ -1,5 +1,18 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { answerFor, revealTarget } from './AssistantModal.logic'
2
+ import * as v from 'valibot'
3
+ import { ASSISTANT_PROMPT_MAX, assistantTurnInputSchema } from '@cat-factory/contracts'
4
+ import type { AssistantCapability } from '~/types/domain'
5
+ import { answerFor, assistantSurface, revealTarget, submitGate } from './AssistantModal.logic'
6
+
7
+ /** A capability that answered. The two halves are independent, so each case states both. */
8
+ function answered(
9
+ available: boolean,
10
+ actions: AssistantCapability['actions'] = ['declare-service-dependency'],
11
+ ): AssistantCapability {
12
+ return { available, actions }
13
+ }
14
+
15
+ const WIRED = answered(true)
3
16
 
4
17
  describe('revealTarget', () => {
5
18
  it('reveals the CONSUMER of a declared dependency, the frame the edge was written onto', () => {
@@ -78,3 +91,118 @@ describe('answerFor', () => {
78
91
  ).toEqual({ issueUrl: 'PROJ-12', source: 'jira' })
79
92
  })
80
93
  })
94
+
95
+ describe('assistantSurface', () => {
96
+ it('shows the BOX while the read is still in flight, rather than a spinner in its place', () => {
97
+ // The modal is opened from the sidebar and from the command palette, so the hands are already
98
+ // on the keyboard. A box that only mounts once the read lands is not focused yet, and every
99
+ // character typed in the gap is dropped; refusing the SUBMIT is what the unanswered read owes,
100
+ // not withholding the thing being typed into.
101
+ expect(assistantSurface('idle', null)).toBe('prompt')
102
+ expect(assistantSurface('loading', null)).toBe('prompt')
103
+ })
104
+
105
+ it('separates a FAILED read from a deployment that wired no model', () => {
106
+ // Retrying is the answer to one and changes nothing about the other, so they cannot share a
107
+ // state: neither one can submit, and that is all they have in common.
108
+ expect(assistantSurface('error', null)).toBe('unreadable')
109
+ expect(assistantSurface('ready', answered(false))).toBe('unwired')
110
+ })
111
+
112
+ it('withholds the box from a wired model with an EMPTY catalog', () => {
113
+ // `available` and `actions` are two independent facts and only the pair decides. A model with
114
+ // nothing to route to renders a box over an empty examples list whose every submit is refused
115
+ // with `assistant_no_actions`, which is the surface this exists to stop offering.
116
+ expect(assistantSurface('ready', answered(true, []))).toBe('no_actions')
117
+ })
118
+
119
+ it('offers the box once a read says a model is wired and the catalog has something in it', () => {
120
+ expect(assistantSurface('ready', WIRED)).toBe('prompt')
121
+ })
122
+
123
+ it('keeps the answer it holds while a RE-read is in flight', () => {
124
+ // Re-opening the modal re-reads the capability. Dropping back to a waiting state for a fact
125
+ // already held would replace the box with a spinner on every open, and take the focus and any
126
+ // half-typed request with it.
127
+ expect(assistantSurface('loading', WIRED)).toBe('prompt')
128
+ expect(assistantSurface('loading', answered(false))).toBe('unwired')
129
+ })
130
+ })
131
+
132
+ describe('submitGate', () => {
133
+ const ready = { prompt: 'the checkout service depends on payments', running: false }
134
+
135
+ it('sends a typed request against a capability that answered', () => {
136
+ expect(submitGate({ read: 'ready', capability: WIRED, ...ready })).toEqual({ state: 'ready' })
137
+ })
138
+
139
+ it('names an empty box, whitespace included, as empty rather than ready', () => {
140
+ expect(
141
+ submitGate({ read: 'ready', capability: WIRED, prompt: ' \t\n ', running: false }),
142
+ ).toEqual({ state: 'empty' })
143
+ })
144
+
145
+ it('reports a turn in flight as running, not as a request with something wrong with it', () => {
146
+ expect(submitGate({ read: 'ready', capability: WIRED, ...ready, running: true })).toEqual({
147
+ state: 'running',
148
+ })
149
+ })
150
+
151
+ it('states the WAIT while the capability read has not answered', () => {
152
+ // The box is offered during the read, so its Run button is disabled for a reason that is
153
+ // nobody's mistake and clears itself. Left unstated it reads as a button that is broken.
154
+ expect(submitGate({ read: 'loading', capability: null, ...ready })).toEqual({
155
+ state: 'checking',
156
+ })
157
+ })
158
+
159
+ it('refuses on a capability that answered it cannot submit, whichever half said so', () => {
160
+ // Unreachable from the modal, which renders no box for these. It is asserted because this is
161
+ // the submit AUTHORITY: a later caller reading only the prompt would submit into a 503.
162
+ expect(submitGate({ read: 'ready', capability: answered(false), ...ready })).toEqual({
163
+ state: 'unavailable',
164
+ })
165
+ expect(submitGate({ read: 'ready', capability: answered(true, []), ...ready })).toEqual({
166
+ state: 'unavailable',
167
+ })
168
+ expect(submitGate({ read: 'error', capability: null, ...ready })).toEqual({
169
+ state: 'unavailable',
170
+ })
171
+ })
172
+
173
+ it('refuses an over-long request with both numbers, so the reason can be stated', () => {
174
+ const length = ASSISTANT_PROMPT_MAX + 43
175
+ expect(
176
+ submitGate({ read: 'ready', capability: WIRED, prompt: 'x'.repeat(length), running: false }),
177
+ ).toEqual({ state: 'too_long', length, limit: ASSISTANT_PROMPT_MAX })
178
+ })
179
+
180
+ it('measures the length the WIRE caps, which is the trimmed text', () => {
181
+ // The schema trims before it counts, so a prompt padded to just over the cap is acceptable
182
+ // and refusing it would be this surface inventing a limit the backend does not hold to.
183
+ const padded = ` ${'x'.repeat(ASSISTANT_PROMPT_MAX)} `
184
+ expect(
185
+ submitGate({ read: 'ready', capability: WIRED, prompt: padded, running: false }),
186
+ ).toEqual({ state: 'ready' })
187
+ })
188
+
189
+ it('refuses at exactly the length the WIRE SCHEMA refuses at', () => {
190
+ // The one invariant the box's whole reason for stating a number rests on, and the one a test
191
+ // pinning a literal cannot see: both sides are asked here, so a cap moved on either side and
192
+ // not the other fails rather than shipping a box that promises a limit the backend does not
193
+ // hold to (or refuses one it would have taken).
194
+ const accepted = (prompt: string) =>
195
+ v.safeParse(assistantTurnInputSchema, { kind: 'prompt', prompt }).success
196
+ const at = 'x'.repeat(ASSISTANT_PROMPT_MAX)
197
+ const over = 'x'.repeat(ASSISTANT_PROMPT_MAX + 1)
198
+
199
+ expect(submitGate({ read: 'ready', capability: WIRED, prompt: at, running: false })).toEqual({
200
+ state: 'ready',
201
+ })
202
+ expect(accepted(at)).toBe(true)
203
+ expect(
204
+ submitGate({ read: 'ready', capability: WIRED, prompt: over, running: false }).state,
205
+ ).toBe('too_long')
206
+ expect(accepted(over)).toBe(false)
207
+ })
208
+ })
@@ -1,4 +1,11 @@
1
- import type { AssistantActionResult, AssistantAnswer, AssistantOutcome } from '~/types/domain'
1
+ import type {
2
+ AssistantActionResult,
3
+ AssistantAnswer,
4
+ AssistantCapability,
5
+ AssistantOutcome,
6
+ } from '~/types/domain'
7
+ import { ASSISTANT_PROMPT_MAX } from '~/types/domain'
8
+ import type { LoadState } from '~/types/load-state'
2
9
 
3
10
  /**
4
11
  * The block a performed turn should reveal on the board.
@@ -43,3 +50,78 @@ export function answerFor(
43
50
  arguments: { ...outcome.arguments, [outcome.field]: candidate },
44
51
  }
45
52
  }
53
+
54
+ /**
55
+ * Which panel the modal shows: the prompt box, or the reason there is none.
56
+ *
57
+ * Derived from the ANSWER rather than from the read's progress, so the states cannot collapse into
58
+ * each other. Three of them are reasons no box is offered, and they differ in what the person does
59
+ * next: `unreadable` is an outage a retry may clear, `unwired` is a deployment with no model, and
60
+ * `no_actions` is a deployment whose catalog is empty. The last two both look like "available is
61
+ * not true enough to submit" and have completely different remedies, which is why an empty catalog
62
+ * is not folded into `unwired`: every submit against one 503s with `assistant_no_actions`, and a
63
+ * box over an empty examples list is exactly the surface this is here to stop offering.
64
+ *
65
+ * A read still IN FLIGHT is not one of them. Withholding the box until the read answers would drop
66
+ * the characters typed in the gap: the modal is opened from the sidebar and from the command
67
+ * palette, where the hands are already on the keyboard, and a textarea that mounts one round trip
68
+ * later is not focused yet, so those keystrokes land nowhere. So an unanswered read shows the box
69
+ * and refuses SUBMISSION with a stated reason ({@link submitGate}), and the box is withheld only
70
+ * once the read has answered that it cannot be submitted.
71
+ */
72
+ export type AssistantSurface = 'prompt' | 'unreadable' | 'unwired' | 'no_actions'
73
+
74
+ export function assistantSurface(
75
+ read: LoadState,
76
+ capability: AssistantCapability | null,
77
+ ): AssistantSurface {
78
+ if (capability === null) return read === 'error' ? 'unreadable' : 'prompt'
79
+ if (!capability.available) return 'unwired'
80
+ return capability.actions.length === 0 ? 'no_actions' : 'prompt'
81
+ }
82
+
83
+ /**
84
+ * Whether the typed request can be sent, and when it cannot, WHY.
85
+ *
86
+ * A disabled button owes an answer, and the reasons differ in what the person does next: an empty
87
+ * box is answered by the placeholder and the examples under it, an over-long one has to name the
88
+ * numbers (nothing on screen tells you a sentence is 40 characters too long), and a request typed
89
+ * while the capability read is still in flight is neither of those: nothing is wrong with it and it
90
+ * becomes sendable on its own, which is why `checking` is stated rather than left to look like a
91
+ * button that does nothing.
92
+ *
93
+ * `unavailable` is the surface's own refusal, restated here rather than left implicit. It never
94
+ * renders, because a surface that is not the box renders no button either. It exists because this
95
+ * function is the submit AUTHORITY: read off the prompt and `running` alone it would answer `ready`
96
+ * for an unwired deployment, and the next caller (a shortcut, a command-palette entry) would
97
+ * inherit a submit that 503s. Deriving it from {@link assistantSurface} is what keeps the two from
98
+ * disagreeing about the same capability.
99
+ *
100
+ * The length is measured on the TRIMMED text because that is what the wire schema caps, and against
101
+ * the schema's OWN constant, so the box cannot promise a limit the backend does not hold to.
102
+ */
103
+ export type SubmitGate =
104
+ | { state: 'ready' }
105
+ | { state: 'running' }
106
+ | { state: 'unavailable' }
107
+ | { state: 'checking' }
108
+ | { state: 'empty' }
109
+ | { state: 'too_long'; length: number; limit: number }
110
+
111
+ export function submitGate(input: {
112
+ read: LoadState
113
+ capability: AssistantCapability | null
114
+ prompt: string
115
+ running: boolean
116
+ }): SubmitGate {
117
+ const { read, capability, prompt, running } = input
118
+ if (running) return { state: 'running' }
119
+ if (assistantSurface(read, capability) !== 'prompt') return { state: 'unavailable' }
120
+ if (capability === null) return { state: 'checking' }
121
+ const length = prompt.trim().length
122
+ if (length === 0) return { state: 'empty' }
123
+ if (length > ASSISTANT_PROMPT_MAX) {
124
+ return { state: 'too_long', length, limit: ASSISTANT_PROMPT_MAX }
125
+ }
126
+ return { state: 'ready' }
127
+ }
@@ -14,11 +14,14 @@
14
14
  // the platform named, with no model call and nothing retyped.
15
15
  // - `declined` means nothing in the catalog matches. It renders the catalog, because "I can't
16
16
  // do that" without saying what it CAN do is the least useful answer this surface can give.
17
- // Everything that IS a failure (no model wired, the budget spent, an unconfigured tracker, an
18
- // issue already filed) arrives as an error and goes through the shared toast funnel with its
19
- // reason, its detail and its request id, the same path a failed button press takes.
17
+ // Everything that IS a failure of a TURN (the budget spent, an unconfigured tracker, an issue
18
+ // already filed) arrives as an error and goes through the shared toast funnel with its reason, its
19
+ // detail and its request id, the same path a failed button press takes. What the CAPABILITY read
20
+ // finds is not on that path: an unwired deployment, an empty catalog and a read that failed are
21
+ // each a panel here, in place of the box, because that is where the person is looking and where
22
+ // the retry belongs.
20
23
  import type { AssistantActionId, AssistantOutcome } from '~/types/domain'
21
- import { answerFor, revealTarget } from './AssistantModal.logic'
24
+ import { answerFor, assistantSurface, revealTarget, submitGate } from './AssistantModal.logic'
22
25
 
23
26
  const { t } = useI18n()
24
27
  const ui = useUiStore()
@@ -44,12 +47,54 @@ const examples = computed(() =>
44
47
  })),
45
48
  )
46
49
 
47
- // Read the capability on open rather than on mount: the modal is lazily loaded, and a deployment
48
- // that wires a provider while the tab is open should not have to be reloaded to offer the box.
49
- watch(open, (isOpen) => {
50
- if (!isOpen) return
50
+ /**
51
+ * Read the capability on every open, INCLUDING the render the modal mounts on.
52
+ *
53
+ * `onModalOpen` rather than a bare `watch` because the page mounts this component only while the
54
+ * open flag is set (`<AssistantModal v-if="ui.assistantOpen">`), so `open` is already true at
55
+ * setup and a change-only watcher never fires at all. Re-reading on each open is what lets a
56
+ * provider wired while the tab is open be picked up with no reload; the previous answer is kept
57
+ * while that re-read is in flight, so a second open shows the box rather than a spinner.
58
+ */
59
+ onModalOpen(open, () => {
51
60
  assistant.reset()
52
- void assistant.loadCapability().catch((error: unknown) => present(error, 'assistant.title'))
61
+ void assistant.loadCapability()
62
+ })
63
+
64
+ /**
65
+ * What the modal shows: the box, or the reason there is none.
66
+ *
67
+ * A read still in flight shows the BOX, disabled, rather than a spinner in its place: this modal is
68
+ * opened from the sidebar and from the command palette, so the hands are already on the keyboard,
69
+ * and a textarea that only mounts once the read lands would swallow whatever was typed in between.
70
+ */
71
+ const surface = computed(() => assistantSurface(assistant.capabilityRead, assistant.capability))
72
+
73
+ /** Whether the request can be sent. */
74
+ const gate = computed(() =>
75
+ submitGate({
76
+ read: assistant.capabilityRead,
77
+ capability: assistant.capability,
78
+ prompt: prompt.value,
79
+ running: assistant.running,
80
+ }),
81
+ )
82
+
83
+ /**
84
+ * Why Run is disabled, where that is not already on screen.
85
+ *
86
+ * An empty box is answered by its own placeholder and the examples under it, and a turn in flight
87
+ * by the button's spinner. Two are stated: a refused LENGTH, because a person who pasted a page has
88
+ * no way to see that it is 143 characters too long, and a capability read still in flight, because
89
+ * a button that will start working on its own in a moment otherwise reads as one that is broken.
90
+ * The line is a live region tied to the button, so the reason reaches a reader that cannot see it.
91
+ */
92
+ const submitReason = computed<string | null>(() => {
93
+ const state = gate.value
94
+ if (state.state === 'too_long') {
95
+ return t('assistant.tooLong', { length: state.length, limit: state.limit })
96
+ }
97
+ return state.state === 'checking' ? t('assistant.reading') : null
53
98
  })
54
99
 
55
100
  /** Editing the prompt clears the previous answer, so an outcome never sits under a new question. */
@@ -57,12 +102,13 @@ watch(prompt, () => {
57
102
  if (outcome.value) assistant.reset()
58
103
  })
59
104
 
60
- const canSubmit = computed(
61
- () => assistant.available && !assistant.running && prompt.value.trim().length > 0,
62
- )
105
+ /** Re-read the capability, from the retry a failed read offers. */
106
+ function retry(): void {
107
+ void assistant.loadCapability()
108
+ }
63
109
 
64
110
  async function submit(): Promise<void> {
65
- if (!canSubmit.value) return
111
+ if (gate.value.state !== 'ready') return
66
112
  try {
67
113
  await assistant.run(prompt.value.trim())
68
114
  } catch (error) {
@@ -110,20 +156,60 @@ function reveal(blockId: string): void {
110
156
  <div class="space-y-4">
111
157
  <p class="text-sm text-slate-400">{{ t('assistant.intro') }}</p>
112
158
 
159
+ <!-- The read FAILED: this deployment may well have a model, and nobody can tell from here.
160
+ So it offers the read again instead of explaining a configuration that may be fine. -->
161
+ <div
162
+ v-if="surface === 'unreadable'"
163
+ class="flex items-start gap-2 rounded-md bg-slate-800/60 p-3 text-sm text-slate-300"
164
+ data-testid="assistant-unreadable"
165
+ >
166
+ <UIcon name="i-lucide-unplug" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
167
+ <div class="space-y-2">
168
+ <p>{{ t('assistant.unreadable') }}</p>
169
+ <UButton
170
+ size="xs"
171
+ variant="soft"
172
+ icon="i-lucide-refresh-cw"
173
+ data-testid="assistant-retry"
174
+ @click="retry"
175
+ >
176
+ {{ t('common.retry') }}
177
+ </UButton>
178
+ </div>
179
+ </div>
180
+
113
181
  <!-- No model wired: say so, rather than offering a box whose every submit would 503. -->
114
182
  <div
115
- v-if="assistant.capability && !assistant.available"
183
+ v-else-if="surface === 'unwired'"
116
184
  class="flex items-start gap-2 rounded-md bg-slate-800/60 p-3 text-sm text-slate-300"
185
+ data-testid="assistant-unwired"
117
186
  >
118
187
  <UIcon name="i-lucide-plug" class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
119
188
  <span>{{ t('assistant.unavailable') }}</span>
120
189
  </div>
121
190
 
191
+ <!-- A model, and nothing for it to do: a different deployment fault with a different fix,
192
+ and every submit against it would be refused with `assistant_no_actions`. -->
193
+ <div
194
+ v-else-if="surface === 'no_actions'"
195
+ class="flex items-start gap-2 rounded-md bg-slate-800/60 p-3 text-sm text-slate-300"
196
+ data-testid="assistant-no-actions"
197
+ >
198
+ <UIcon name="i-lucide-list-x" class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
199
+ <span>{{ t('assistant.noActions') }}</span>
200
+ </div>
201
+
122
202
  <template v-else>
203
+ <!-- Full width and roomy: a request is a sentence or three, and the box it is typed in
204
+ is the whole surface. `autoresize` grows it with the text up to `maxrows`, after
205
+ which it scrolls rather than pushing the examples and the outcome off the modal. -->
123
206
  <UTextarea
124
207
  v-model="prompt"
125
- :rows="3"
208
+ :rows="6"
209
+ autoresize
210
+ :maxrows="14"
126
211
  autofocus
212
+ class="w-full"
127
213
  :disabled="assistant.running"
128
214
  :placeholder="t('assistant.placeholder')"
129
215
  data-testid="assistant-prompt"
@@ -136,13 +222,34 @@ function reveal(blockId: string): void {
136
222
  color="primary"
137
223
  icon="i-lucide-sparkles"
138
224
  :loading="assistant.running"
139
- :disabled="!canSubmit"
225
+ :disabled="gate.state !== 'ready'"
226
+ aria-describedby="assistant-submit-reason"
140
227
  data-testid="assistant-submit"
141
228
  @click="submit"
142
229
  >
143
230
  {{ t('assistant.submit') }}
144
231
  </UButton>
145
- <span class="text-xs text-slate-500">{{ t('assistant.submitHint') }}</span>
232
+ <!-- The stated reason takes the keyboard hint's place while it applies. ONE element
233
+ for both, named by the button it explains and present from the first render: a
234
+ disabled button is out of the tab order and announces nothing, so a reason that
235
+ only appears in a span nothing points at leaves the one reader who cannot see it
236
+ with a Run that does nothing and no reason given. It announces only while it is
237
+ carrying a REASON; the keyboard hint is standing information, not news. -->
238
+ <span
239
+ id="assistant-submit-reason"
240
+ role="status"
241
+ :aria-live="submitReason ? 'polite' : 'off'"
242
+ class="flex items-center gap-1 text-xs"
243
+ :class="submitReason ? 'text-amber-400' : 'text-slate-500'"
244
+ data-testid="assistant-submit-status"
245
+ >
246
+ <UIcon
247
+ v-if="gate.state === 'checking'"
248
+ name="i-lucide-loader-circle"
249
+ class="h-3 w-3 shrink-0 animate-spin"
250
+ />
251
+ {{ submitReason ?? t('assistant.submitHint') }}
252
+ </span>
146
253
  </div>
147
254
 
148
255
  <!-- What it can do, always visible: the catalog is the affordance. -->
@@ -613,8 +613,7 @@ async function resolvePendingIssueBodies() {
613
613
 
614
614
  // Reset the form whenever the modal opens for a (new) container, and refresh the
615
615
  // imported docs/issues so the quick-pick list is current.
616
- watch(open, (isOpen) => {
617
- if (!isOpen) return
616
+ onModalOpen(open, () => {
618
617
  title.value = ''
619
618
  description.value = ''
620
619
  saving.value = false
@@ -15,7 +15,7 @@
15
15
  // planning pipeline then reads: the interviewer stops asking what an attached document already
16
16
  // answers, and the analyst and planner ground the plan in it. Linking needs a block id, so picks
17
17
  // are staged and committed once the initiative exists (the add-task flow's shared orchestration).
18
- import { computed, ref, watch } from 'vue'
18
+ import { computed, ref } from 'vue'
19
19
  import {
20
20
  sanitizeInitiativePresetInputs,
21
21
  validateInitiativePresetInputs,
@@ -112,8 +112,7 @@ function selectPreset(id: string): void {
112
112
  applyPreset()
113
113
  }
114
114
 
115
- watch(open, (o) => {
116
- if (!o) return
115
+ onModalOpen(open, () => {
117
116
  title.value = ''
118
117
  description.value = ''
119
118
  submitting.value = false
@@ -228,8 +228,7 @@ const intakeIssueTypeApplies = computed(() =>
228
228
  appliesIntakePredicate(intakeSourceState.value, 'issueType'),
229
229
  )
230
230
 
231
- watch(open, (isOpen) => {
232
- if (!isOpen) return
231
+ onModalOpen(open, () => {
233
232
  name.value = ''
234
233
  description.value = ''
235
234
  // Default to the first schedulable pipeline, which is the ladder's own default rung. There is no
@@ -64,8 +64,8 @@ async function connectWithOAuth() {
64
64
  }
65
65
  }
66
66
 
67
- watch(open, (isOpen) => {
68
- if (isOpen) values.value = {}
67
+ onModalOpen(open, () => {
68
+ values.value = {}
69
69
  })
70
70
 
71
71
  const canSubmit = computed(() => {
@@ -52,8 +52,7 @@ const claimant = ref<DocumentSourceKind | null>(null)
52
52
  */
53
53
  const resolvedFor = ref<string | null>(null)
54
54
 
55
- watch(open, (isOpen) => {
56
- if (!isOpen) return
55
+ onModalOpen(open, () => {
57
56
  pasted.value = ''
58
57
  state.value = { status: 'none' }
59
58
  claimant.value = null
@@ -226,8 +226,7 @@ function onKeydown(event: KeyboardEvent) {
226
226
 
227
227
  // Reset the query each time the bar opens, and focus the input.
228
228
  const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
229
- watch(open, (isOpen) => {
230
- if (!isOpen) return
229
+ onModalOpen(open, () => {
231
230
  query.value = ''
232
231
  activeIndex.value = 0
233
232
  void documents.probe()
@@ -141,6 +141,7 @@ const TESTS_GAP_KEYS: Record<TestsGap, string> = {
141
141
  run_unavailable: RUN_UNAVAILABLE_KEY,
142
142
  no_tester_step: 'outcome.tests.gap.no_tester_step',
143
143
  tester_not_reported: 'outcome.tests.gap.tester_not_reported',
144
+ verified_by_committed_tests: 'outcome.tests.gap.verified_by_committed_tests',
144
145
  }
145
146
  const SOURCES_GAP_KEYS: Record<SourcesGap, string> = {
146
147
  run_unavailable: RUN_UNAVAILABLE_KEY,
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { DEPLOYER_AGENT_KIND } from '@cat-factory/contracts'
4
4
  import type { AgentKind, Pipeline } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
@@ -247,20 +247,20 @@ const open = computed({
247
247
  // Refresh the observability-integration state whenever the builder opens so the palette
248
248
  // knows whether to offer the post-release-health gate (it's loaded on demand, not from
249
249
  // the snapshot). Best-effort: a failure just leaves the gate hidden.
250
- watch(open, (isOpen) => {
251
- if (isOpen) releaseHealth.load().catch(() => {})
250
+ onModalOpen(open, () => {
251
+ releaseHealth.load().catch(() => {})
252
252
  // The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
253
253
  // Best-effort: the builder is fully usable without it, and a deployment that wires no
254
254
  // override store answers 503 here.
255
- if (isOpen) agentPrompts.loadIndex().catch(() => {})
255
+ agentPrompts.loadIndex().catch(() => {})
256
256
  // The workspace's per-kind output ceilings, which the per-step field shows as its inherited
257
257
  // placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
258
- if (isOpen) agentSettings.load().catch(() => {})
258
+ agentSettings.load().catch(() => {})
259
259
  // The resolved foundational-services catalog, which the binary-output picker offers from.
260
260
  // Single-flighted per workspace, so this shares the panel's load rather than adding one. A
261
261
  // failure is not swallowed into an empty picker: the store records `available: false`, and
262
262
  // the picker says the catalog is unreachable rather than "no services exist".
263
- if (isOpen) void foundational.ensureProbed()
263
+ void foundational.ensureProbed()
264
264
  })
265
265
 
266
266
  function add(kind: AgentKind) {
@@ -4,7 +4,7 @@
4
4
  // personalSubscriptions store's `pending` state, which is set when the server replies 428
5
5
  // credential_required. On submit it transparently retries the gated action and caches the
6
6
  // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
- import { computed, ref, watch } from 'vue'
7
+ import { computed, ref } from 'vue'
8
8
  import SecretInput from '~/components/common/SecretInput.vue'
9
9
 
10
10
  const { t } = useI18n()
@@ -45,8 +45,8 @@ const vendorLabel = computed(() => {
45
45
  }
46
46
  })
47
47
 
48
- watch(open, (isOpen) => {
49
- if (isOpen) password.value = ''
48
+ onModalOpen(open, () => {
49
+ password.value = ''
50
50
  })
51
51
 
52
52
  const title = computed(() => {
@@ -229,8 +229,7 @@ const request = computed(() =>
229
229
  )
230
230
  const canHunt = computed(() => request.value !== null)
231
231
 
232
- watch(open, (isOpen) => {
233
- if (!isOpen) return
232
+ onModalOpen(open, () => {
234
233
  hunt.reset()
235
234
  boardId.value = ''
236
235
  issueType.value = ''
@@ -70,13 +70,11 @@ const title = computed(() =>
70
70
  pinnedContainer.value ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
71
71
  )
72
72
 
73
- watch(open, (isOpen) => {
74
- if (isOpen) {
75
- ref_.value = ''
76
- source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
77
- resetContainer()
78
- tasks.loadTasks().catch(() => {})
79
- }
73
+ onModalOpen(open, () => {
74
+ ref_.value = ''
75
+ source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
76
+ resetContainer()
77
+ tasks.loadTasks().catch(() => {})
80
78
  })
81
79
 
82
80
  // Choosing an issue in the picker hands off to the add-task form, prefilled with the
@@ -45,8 +45,8 @@ const values = ref<Record<string, string>>({})
45
45
  const saving = ref(false)
46
46
  const togglingEnabled = ref(false)
47
47
 
48
- watch(open, (isOpen) => {
49
- if (isOpen) values.value = {}
48
+ onModalOpen(open, () => {
49
+ values.value = {}
50
50
  })
51
51
 
52
52
  const canSubmit = computed(() => {
@@ -6,9 +6,11 @@ import type { ApiContext } from './context'
6
6
  export function assistantApi({ send, ws }: ApiContext) {
7
7
  return {
8
8
  // Whether a model is wired and which actions this deployment offers. Read before the prompt
9
- // box is shown, so an unconfigured deployment says so instead of failing on submit.
10
- getAssistantCapability: (workspaceId: string) =>
11
- send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId) }),
9
+ // box is shown, so an unconfigured deployment says so instead of failing on submit. `signal`
10
+ // is what lets the store put a deadline on it: the client sets no timeout of its own, and a
11
+ // read that never settles is a modal with no answer, no failure and so no retry either.
12
+ getAssistantCapability: (workspaceId: string, signal?: AbortSignal) =>
13
+ send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId), signal }),
12
14
 
13
15
  // Run one turn. A live model call plus a board write, so it can take a couple of seconds:
14
16
  // the modal shows progress and the outcome is rendered from the returned data.