@cat-factory/app 0.300.1 → 0.301.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.
Files changed (41) 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/panels/inspector/ServiceTestingContext.logic.spec.ts +38 -0
  12. package/app/components/panels/inspector/ServiceTestingContext.logic.ts +37 -0
  13. package/app/components/panels/inspector/ServiceTestingContext.vue +141 -0
  14. package/app/components/pipeline/PipelineBuilder.vue +6 -6
  15. package/app/components/providers/PersonalCredentialModal.vue +3 -3
  16. package/app/components/tasks/BugHuntModal.vue +1 -2
  17. package/app/components/tasks/TaskImportModal.vue +5 -7
  18. package/app/components/tasks/TaskSourceConnectModal.vue +2 -2
  19. package/app/composables/api/assistant.ts +5 -3
  20. package/app/composables/useArtifactBlobs.ts +2 -1
  21. package/app/composables/useModalOpen.spec.ts +51 -0
  22. package/app/composables/useModalOpen.ts +31 -0
  23. package/app/modular/panels/inspector.logic.spec.ts +3 -0
  24. package/app/modular/panels/inspector.logic.ts +5 -0
  25. package/app/modular/panels/inspector.ts +2 -0
  26. package/app/stores/assistant.spec.ts +183 -0
  27. package/app/stores/assistant.ts +83 -7
  28. package/app/types/domain.ts +4 -0
  29. package/app/types/load-state.ts +18 -0
  30. package/app/utils/catalog.ts +21 -0
  31. package/i18n/locales/de.json +14 -0
  32. package/i18n/locales/en.json +23 -0
  33. package/i18n/locales/es.json +14 -0
  34. package/i18n/locales/fr.json +14 -0
  35. package/i18n/locales/he.json +14 -0
  36. package/i18n/locales/it.json +14 -0
  37. package/i18n/locales/ja.json +14 -0
  38. package/i18n/locales/pl.json +14 -0
  39. package/i18n/locales/tr.json +14 -0
  40. package/i18n/locales/uk.json +14 -0
  41. 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()
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { rehydratedDraft } from './ServiceTestingContext.logic'
3
+
4
+ const TYPED = 'Sign in as $DEMO_USER. The seeded tenant is Acme.'
5
+
6
+ describe('rehydratedDraft', () => {
7
+ it('follows the board while the operator has typed nothing of their own', () => {
8
+ // A teammate saves in another tab: the live board event is the only thing that will ever tell
9
+ // this textarea, so an untouched draft has to take it.
10
+ const arrives = { draft: '', previous: '', incoming: TYPED, saving: false }
11
+ expect(rehydratedDraft(arrives)).toBe(TYPED)
12
+ expect(rehydratedDraft({ ...arrives, draft: 'old prose', previous: 'old prose' })).toBe(TYPED)
13
+ })
14
+
15
+ it('leaves an in-progress edit alone when someone else saves', () => {
16
+ expect(
17
+ rehydratedDraft({ draft: TYPED, previous: '', incoming: 'their prose', saving: false }),
18
+ ).toBe(TYPED)
19
+ })
20
+
21
+ it('keeps unsaved prose through the whole of a REJECTED save', () => {
22
+ // The sequence the board store produces for a first-time write that fails: the optimistic
23
+ // patch announces our own text, then the rollback announces the value we started from. The
24
+ // second one is the dangerous one, because by then the draft matches what the block last said
25
+ // and the change reads exactly like a teammate's edit. Both land while `saving`.
26
+ const optimistic = { draft: TYPED, previous: '', incoming: TYPED, saving: true }
27
+ expect(rehydratedDraft(optimistic)).toBe(TYPED)
28
+ const rolledBack = { draft: TYPED, previous: TYPED, incoming: '', saving: true }
29
+ expect(rehydratedDraft(rolledBack)).toBe(TYPED)
30
+ })
31
+
32
+ it('keeps keystrokes typed while a save is in flight', () => {
33
+ const stillTyping = `${TYPED} Never run the billing flow.`
34
+ expect(
35
+ rehydratedDraft({ draft: stillTyping, previous: '', incoming: TYPED, saving: true }),
36
+ ).toBe(stillTyping)
37
+ })
38
+ })
@@ -0,0 +1,37 @@
1
+ // The pure half of ServiceTestingContext: when a testing context arriving from the board may
2
+ // replace what the operator has typed into the textarea. Extracted for the reason every
3
+ // `*.logic.ts` here is (a decision worth a test should not need a mounted component to reach),
4
+ // and this one is a rule whose failure mode is silent and expensive.
5
+
6
+ /** The block value moving under the textarea, plus what the textarea currently holds. */
7
+ export interface DraftRehydration {
8
+ /** What the operator has in the textarea right now. */
9
+ draft: string
10
+ /** The persisted value the draft was last in step with (the watcher's previous value). */
11
+ previous: string
12
+ /** The persisted value that just arrived. */
13
+ incoming: string
14
+ /** Whether THIS panel has a save in flight. */
15
+ saving: boolean
16
+ }
17
+
18
+ /**
19
+ * The draft to hold after the block's persisted testing context moved.
20
+ *
21
+ * An UNTOUCHED draft follows the board, so a teammate's edit lands live; a touched one is left
22
+ * alone. "Untouched" is measured against the value the draft was last in step with rather than
23
+ * against a dirty flag, because during an edit both are equally "different from what is stored"
24
+ * and only one of them may be overwritten.
25
+ *
26
+ * WHILE THIS PANEL IS SAVING, nothing is taken at all, and that is the case worth the extra state:
27
+ * the board store patches the block OPTIMISTICALLY and restores the old value if the request
28
+ * fails, so a rejected save arrives as a value change whose `previous` is our own optimistic write,
29
+ * which is exactly what an untouched draft looks like. Following it would hand the operator an
30
+ * emptied textarea, an "update failed" toast, and no copy of what they wrote. Every value change
31
+ * inside that window is ours (the optimistic write, the server's echo, the rollback), so ignoring
32
+ * the lot also keeps the keystrokes typed while the request was in flight.
33
+ */
34
+ export function rehydratedDraft(state: DraftRehydration): string {
35
+ if (state.saving) return state.draft
36
+ return state.draft === state.previous ? state.incoming : state.draft
37
+ }