@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.
- package/README.md +21 -0
- package/app/components/assistant/AssistantModal.logic.spec.ts +129 -1
- package/app/components/assistant/AssistantModal.logic.ts +83 -1
- package/app/components/assistant/AssistantModal.vue +124 -17
- package/app/components/board/AddTaskModal.vue +1 -2
- package/app/components/board/CreateInitiativeModal.vue +2 -3
- package/app/components/board/RecurringPipelineModal.vue +1 -2
- package/app/components/documents/DocumentSourceConnectModal.vue +2 -2
- package/app/components/documents/StartFromDesignModal.vue +1 -2
- package/app/components/layout/CommandBar.vue +1 -2
- package/app/components/panels/inspector/ServiceTestingContext.logic.spec.ts +38 -0
- package/app/components/panels/inspector/ServiceTestingContext.logic.ts +37 -0
- package/app/components/panels/inspector/ServiceTestingContext.vue +141 -0
- package/app/components/pipeline/PipelineBuilder.vue +6 -6
- package/app/components/providers/PersonalCredentialModal.vue +3 -3
- package/app/components/tasks/BugHuntModal.vue +1 -2
- package/app/components/tasks/TaskImportModal.vue +5 -7
- package/app/components/tasks/TaskSourceConnectModal.vue +2 -2
- package/app/composables/api/assistant.ts +5 -3
- package/app/composables/useArtifactBlobs.ts +2 -1
- package/app/composables/useModalOpen.spec.ts +51 -0
- package/app/composables/useModalOpen.ts +31 -0
- package/app/modular/panels/inspector.logic.spec.ts +3 -0
- package/app/modular/panels/inspector.logic.ts +5 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/assistant.spec.ts +183 -0
- package/app/stores/assistant.ts +83 -7
- package/app/types/domain.ts +4 -0
- package/app/types/load-state.ts +18 -0
- package/app/utils/catalog.ts +21 -0
- package/i18n/locales/de.json +14 -0
- package/i18n/locales/en.json +23 -0
- package/i18n/locales/es.json +14 -0
- package/i18n/locales/fr.json +14 -0
- package/i18n/locales/he.json +14 -0
- package/i18n/locales/it.json +14 -0
- package/i18n/locales/ja.json +14 -0
- package/i18n/locales/pl.json +14 -0
- package/i18n/locales/tr.json +14 -0
- package/i18n/locales/uk.json +14 -0
- 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
|
|
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 {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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()
|
|
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
|
-
|
|
61
|
-
|
|
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 (
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -52,8 +52,7 @@ const claimant = ref<DocumentSourceKind | null>(null)
|
|
|
52
52
|
*/
|
|
53
53
|
const resolvedFor = ref<string | null>(null)
|
|
54
54
|
|
|
55
|
-
|
|
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
|
-
|
|
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
|
+
}
|