@cat-factory/app 0.299.0 → 0.300.1

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.
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { answerFor, revealTarget } from './AssistantModal.logic'
3
+
4
+ describe('revealTarget', () => {
5
+ it('reveals the CONSUMER of a declared dependency, the frame the edge was written onto', () => {
6
+ expect(
7
+ revealTarget({
8
+ actionId: 'declare-service-dependency',
9
+ consumer: { blockId: 'f1', title: 'Checkout' },
10
+ provider: { blockId: 'f2', title: 'Payments' },
11
+ created: true,
12
+ }),
13
+ ).toBe('f1')
14
+ })
15
+
16
+ it('reveals the new service frame', () => {
17
+ expect(
18
+ revealTarget({
19
+ actionId: 'add-service-from-repo',
20
+ service: { blockId: 'f3', title: 'payments' },
21
+ repo: { owner: 'acme', name: 'payments', directory: null },
22
+ created: true,
23
+ }),
24
+ ).toBe('f3')
25
+ })
26
+
27
+ it('reveals the task, not the service it landed in', () => {
28
+ expect(
29
+ revealTarget({
30
+ actionId: 'create-task-from-issue',
31
+ task: { blockId: 't1', title: 'Card charges time out' },
32
+ service: { blockId: 'f2', title: 'Payments' },
33
+ issue: { source: 'github', externalId: 'acme/payments#12', url: 'https://example.test' },
34
+ }),
35
+ ).toBe('t1')
36
+ })
37
+ })
38
+
39
+ describe('answerFor', () => {
40
+ it('puts the chosen candidate in the field the platform named, keeping the rest', () => {
41
+ expect(
42
+ answerFor(
43
+ {
44
+ status: 'needs_input',
45
+ actionId: 'declare-service-dependency',
46
+ reason: 'ambiguous_service',
47
+ field: 'consumer',
48
+ candidates: ['Payments API', 'API Gateway'],
49
+ arguments: { consumer: 'api', provider: 'Ledger', description: 'reads balances' },
50
+ },
51
+ 'Payments API',
52
+ ),
53
+ ).toEqual({
54
+ actionId: 'declare-service-dependency',
55
+ arguments: {
56
+ consumer: 'Payments API',
57
+ provider: 'Ledger',
58
+ description: 'reads balances',
59
+ },
60
+ })
61
+ })
62
+
63
+ it('answers a field the failed turn never resolved a value for', () => {
64
+ // The ambiguous-tracker case: `source` is absent from the arguments precisely because the
65
+ // request never named one, and the answer is what supplies it.
66
+ expect(
67
+ answerFor(
68
+ {
69
+ status: 'needs_input',
70
+ actionId: 'create-task-from-issue',
71
+ reason: 'ambiguous_issue_source',
72
+ field: 'source',
73
+ candidates: ['jira', 'linear'],
74
+ arguments: { issueUrl: 'PROJ-12' },
75
+ },
76
+ 'jira',
77
+ ).arguments,
78
+ ).toEqual({ issueUrl: 'PROJ-12', source: 'jira' })
79
+ })
80
+ })
@@ -0,0 +1,45 @@
1
+ import type { AssistantActionResult, AssistantAnswer, AssistantOutcome } from '~/types/domain'
2
+
3
+ /**
4
+ * The block a performed turn should reveal on the board.
5
+ *
6
+ * Per action, the SUBJECT of what happened rather than the first id in the result: a filed task
7
+ * is the task, an added service is its frame, and a declared dependency is the CONSUMER, which is
8
+ * the frame the edge was written onto and the one whose panel shows it. Selecting the provider
9
+ * there would open the service that did not change.
10
+ *
11
+ * Exhaustive over the action union, so a fourth action fails to compile until it says what its
12
+ * turn produced. That is the point of extracting three lines: a `default` that fell back to some
13
+ * id would silently reveal the wrong block for whatever is added next.
14
+ */
15
+ export function revealTarget(result: AssistantActionResult): string {
16
+ switch (result.actionId) {
17
+ case 'declare-service-dependency':
18
+ return result.consumer.blockId
19
+ case 'add-service-from-repo':
20
+ return result.service.blockId
21
+ case 'create-task-from-issue':
22
+ return result.task.blockId
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The answer to a clarification, once a person has picked one of its candidates.
28
+ *
29
+ * The chosen value REPLACES the field the platform named and everything else is carried over
30
+ * untouched, so the next turn re-runs the same action with the one unresolved argument settled.
31
+ *
32
+ * Appending the candidate to the prompt and routing it again is what this replaces, and it could
33
+ * not terminate: the words that produced the question are still in the sentence (a repository
34
+ * under the wrong owner sits there beside the right one), and a candidate with no declared
35
+ * argument to land in is dropped on the way through.
36
+ */
37
+ export function answerFor(
38
+ outcome: Extract<AssistantOutcome, { status: 'needs_input' }>,
39
+ candidate: string,
40
+ ): AssistantAnswer {
41
+ return {
42
+ actionId: outcome.actionId,
43
+ arguments: { ...outcome.arguments, [outcome.field]: candidate },
44
+ }
45
+ }
@@ -0,0 +1,260 @@
1
+ <script setup lang="ts">
2
+ // The in-app assistant: type what you want done, and the platform does it.
3
+ //
4
+ // The whole surface is one prompt box and one OUTCOME, and the outcome is rendered from the
5
+ // turn's DATA rather than from anything a model wrote. That is not a stylistic choice: the
6
+ // backend deliberately puts no model prose on the wire (a turn answers with the action it
7
+ // performed, or a machine-readable reason it could not), so every sentence here comes out of the
8
+ // i18n catalog and reads the same in every locale.
9
+ //
10
+ // Two of the three outcomes are not failures and must not look like one:
11
+ // - `needs_input` is a QUESTION. Where the platform had candidates (two services matching the
12
+ // name, a repository under a different owner, two trackers that both read the reference) each
13
+ // is a chip that ANSWERS it: one click re-runs the same action with that value in the field
14
+ // the platform named, with no model call and nothing retyped.
15
+ // - `declined` means nothing in the catalog matches. It renders the catalog, because "I can't
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.
20
+ import type { AssistantActionId, AssistantOutcome } from '~/types/domain'
21
+ import { answerFor, revealTarget } from './AssistantModal.logic'
22
+
23
+ const { t } = useI18n()
24
+ const ui = useUiStore()
25
+ const assistant = useAssistantStore()
26
+ const { present } = usePipelineErrorToast()
27
+
28
+ const open = computed({
29
+ get: () => ui.assistantOpen,
30
+ set: (value: boolean) => (value ? ui.openAssistant() : ui.closeAssistant()),
31
+ })
32
+
33
+ const prompt = ref('')
34
+
35
+ /** The outcome of the last turn, or null before the first one / after the prompt is edited. */
36
+ const outcome = computed<AssistantOutcome | null>(() => assistant.turn?.outcome ?? null)
37
+
38
+ /** The example prompts offered as a starting point, one per action this deployment can perform. */
39
+ const examples = computed(() =>
40
+ assistant.actions.map((actionId: AssistantActionId) => ({
41
+ actionId,
42
+ label: t(`assistant.actions.${actionId}.label`),
43
+ example: t(`assistant.actions.${actionId}.example`),
44
+ })),
45
+ )
46
+
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
51
+ assistant.reset()
52
+ void assistant.loadCapability().catch((error: unknown) => present(error, 'assistant.title'))
53
+ })
54
+
55
+ /** Editing the prompt clears the previous answer, so an outcome never sits under a new question. */
56
+ watch(prompt, () => {
57
+ if (outcome.value) assistant.reset()
58
+ })
59
+
60
+ const canSubmit = computed(
61
+ () => assistant.available && !assistant.running && prompt.value.trim().length > 0,
62
+ )
63
+
64
+ async function submit(): Promise<void> {
65
+ if (!canSubmit.value) return
66
+ try {
67
+ await assistant.run(prompt.value.trim())
68
+ } catch (error) {
69
+ present(error, 'assistant.title')
70
+ }
71
+ }
72
+
73
+ /** Put an example in the box, ready to edit, never submitted for the person. */
74
+ function useExample(example: string): void {
75
+ prompt.value = example
76
+ assistant.reset()
77
+ }
78
+
79
+ /**
80
+ * Answer a clarification with one of the candidates the platform offered.
81
+ *
82
+ * Submitted as DATA, not as more prose: the turn re-runs the action it was already heading for
83
+ * with the chosen value in the field it named. Appending the candidate to the sentence and asking
84
+ * the model again is what this replaces, and it could not settle the question (see `answerFor`).
85
+ * The prompt box is left exactly as the person typed it, because the request has not changed.
86
+ */
87
+ async function useCandidate(
88
+ question: Extract<AssistantOutcome, { status: 'needs_input' }>,
89
+ candidate: string,
90
+ ): Promise<void> {
91
+ if (assistant.running) return
92
+ try {
93
+ await assistant.answer(answerFor(question, candidate))
94
+ } catch (error) {
95
+ present(error, 'assistant.title')
96
+ }
97
+ }
98
+
99
+ /** Select what a performed turn produced, and close, so the board shows it straight away. */
100
+ function reveal(blockId: string): void {
101
+ ui.select(blockId)
102
+ ui.focus(blockId)
103
+ ui.closeAssistant()
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <UModal v-model:open="open" :title="t('assistant.title')" :ui="{ content: 'max-w-2xl' }">
109
+ <template #body>
110
+ <div class="space-y-4">
111
+ <p class="text-sm text-slate-400">{{ t('assistant.intro') }}</p>
112
+
113
+ <!-- No model wired: say so, rather than offering a box whose every submit would 503. -->
114
+ <div
115
+ v-if="assistant.capability && !assistant.available"
116
+ class="flex items-start gap-2 rounded-md bg-slate-800/60 p-3 text-sm text-slate-300"
117
+ >
118
+ <UIcon name="i-lucide-plug" class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
119
+ <span>{{ t('assistant.unavailable') }}</span>
120
+ </div>
121
+
122
+ <template v-else>
123
+ <UTextarea
124
+ v-model="prompt"
125
+ :rows="3"
126
+ autofocus
127
+ :disabled="assistant.running"
128
+ :placeholder="t('assistant.placeholder')"
129
+ data-testid="assistant-prompt"
130
+ @keydown.enter.meta.prevent="submit"
131
+ @keydown.enter.ctrl.prevent="submit"
132
+ />
133
+
134
+ <div class="flex flex-wrap items-center gap-2">
135
+ <UButton
136
+ color="primary"
137
+ icon="i-lucide-sparkles"
138
+ :loading="assistant.running"
139
+ :disabled="!canSubmit"
140
+ data-testid="assistant-submit"
141
+ @click="submit"
142
+ >
143
+ {{ t('assistant.submit') }}
144
+ </UButton>
145
+ <span class="text-xs text-slate-500">{{ t('assistant.submitHint') }}</span>
146
+ </div>
147
+
148
+ <!-- What it can do, always visible: the catalog is the affordance. -->
149
+ <div v-if="examples.length" class="space-y-2">
150
+ <p class="text-xs font-medium uppercase tracking-wide text-slate-500">
151
+ {{ t('assistant.examplesTitle') }}
152
+ </p>
153
+ <div class="flex flex-col gap-1">
154
+ <button
155
+ v-for="entry in examples"
156
+ :key="entry.actionId"
157
+ type="button"
158
+ class="rounded-md px-2 py-1 text-left text-sm text-slate-300 hover:bg-slate-800"
159
+ @click="useExample(entry.example)"
160
+ >
161
+ <span class="text-slate-400">{{ entry.label }}</span>
162
+ <span class="block text-xs text-slate-500">“{{ entry.example }}”</span>
163
+ </button>
164
+ </div>
165
+ </div>
166
+
167
+ <!-- The outcome. Rendered from the turn's data; no model prose reaches this. -->
168
+ <div
169
+ v-if="outcome"
170
+ class="rounded-md border border-slate-700 p-3 text-sm"
171
+ data-testid="assistant-outcome"
172
+ >
173
+ <template v-if="outcome.status === 'performed'">
174
+ <div class="flex items-start gap-2 text-slate-200">
175
+ <UIcon name="i-lucide-check" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-400" />
176
+ <div class="space-y-2">
177
+ <p v-if="outcome.result.actionId === 'declare-service-dependency'">
178
+ {{
179
+ t(
180
+ outcome.result.created
181
+ ? 'assistant.done.dependencyDeclared'
182
+ : 'assistant.done.dependencyAlready',
183
+ {
184
+ consumer: outcome.result.consumer.title,
185
+ provider: outcome.result.provider.title,
186
+ },
187
+ )
188
+ }}
189
+ </p>
190
+ <p v-else-if="outcome.result.actionId === 'add-service-from-repo'">
191
+ {{
192
+ t(
193
+ outcome.result.created
194
+ ? 'assistant.done.serviceAdded'
195
+ : 'assistant.done.serviceMounted',
196
+ {
197
+ service: outcome.result.service.title,
198
+ repo: `${outcome.result.repo.owner}/${outcome.result.repo.name}`,
199
+ },
200
+ )
201
+ }}
202
+ </p>
203
+ <p v-else>
204
+ {{
205
+ t('assistant.done.taskCreated', {
206
+ task: outcome.result.task.title,
207
+ service: outcome.result.service.title,
208
+ issue: outcome.result.issue.externalId,
209
+ })
210
+ }}
211
+ </p>
212
+ <UButton
213
+ size="xs"
214
+ variant="soft"
215
+ icon="i-lucide-crosshair"
216
+ @click="reveal(revealTarget(outcome.result))"
217
+ >
218
+ {{ t('assistant.showOnBoard') }}
219
+ </UButton>
220
+ </div>
221
+ </div>
222
+ </template>
223
+
224
+ <template v-else-if="outcome.status === 'needs_input'">
225
+ <div class="flex items-start gap-2 text-slate-200">
226
+ <UIcon name="i-lucide-help-circle" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
227
+ <div class="space-y-2">
228
+ <p>{{ t(`assistant.needsInput.${outcome.reason}`) }}</p>
229
+ <div v-if="outcome.candidates.length" class="flex flex-wrap gap-1">
230
+ <UButton
231
+ v-for="candidate in outcome.candidates"
232
+ :key="candidate"
233
+ size="xs"
234
+ variant="soft"
235
+ :disabled="assistant.running"
236
+ data-testid="assistant-candidate"
237
+ @click="useCandidate(outcome, candidate)"
238
+ >
239
+ {{ candidate }}
240
+ </UButton>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </template>
245
+
246
+ <template v-else>
247
+ <div class="flex items-start gap-2 text-slate-200">
248
+ <UIcon
249
+ name="i-lucide-circle-slash"
250
+ class="mt-0.5 h-4 w-4 shrink-0 text-slate-500"
251
+ />
252
+ <p>{{ t('assistant.declined') }}</p>
253
+ </div>
254
+ </template>
255
+ </div>
256
+ </template>
257
+ </div>
258
+ </template>
259
+ </UModal>
260
+ </template>
@@ -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,
@@ -0,0 +1,29 @@
1
+ import { getAssistantCapabilityContract, runAssistantTurnContract } from '@cat-factory/contracts'
2
+ import type { AssistantAnswer } from '~/types/domain'
3
+ import type { ApiContext } from './context'
4
+
5
+ /** In-app assistant: what it can do here, and one prompt-to-action turn. */
6
+ export function assistantApi({ send, ws }: ApiContext) {
7
+ return {
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) }),
12
+
13
+ // Run one turn. A live model call plus a board write, so it can take a couple of seconds:
14
+ // the modal shows progress and the outcome is rendered from the returned data.
15
+ runAssistantTurn: (workspaceId: string, prompt: string) =>
16
+ send(runAssistantTurnContract, {
17
+ pathPrefix: ws(workspaceId),
18
+ body: { kind: 'prompt', prompt },
19
+ }),
20
+
21
+ // Answer a question the last turn asked. The same endpoint, and deliberately: it performs one
22
+ // catalog action exactly as a prompt does. It reaches no model, so it is the fast half.
23
+ answerAssistantTurn: (workspaceId: string, answer: AssistantAnswer) =>
24
+ send(runAssistantTurnContract, {
25
+ pathPrefix: ws(workspaceId),
26
+ body: { kind: 'answer', answer },
27
+ }),
28
+ }
29
+ }
@@ -54,6 +54,7 @@ import { reviewsApi } from './api/reviews'
54
54
  import { slackApi } from './api/slack'
55
55
  import { specApi } from './api/spec'
56
56
  import { tasksApi } from './api/tasks'
57
+ import { assistantApi } from './api/assistant'
57
58
  import { bugHuntApi } from './api/bugHunt'
58
59
  import { testSecretsApi } from './api/testSecrets'
59
60
  import { userSecretsApi } from './api/userSecrets'
@@ -131,6 +132,7 @@ export function useApi() {
131
132
  ...executionApi(ctx),
132
133
  ...documentsApi(ctx),
133
134
  ...tasksApi(ctx),
135
+ ...assistantApi(ctx),
134
136
  ...bugHuntApi(ctx),
135
137
  ...reviewsApi(ctx),
136
138
  ...followUpsApi(ctx),
@@ -40,6 +40,7 @@ export function useNavContributions() {
40
40
  // with no catalog entry) is a compile error, not a silently dead button.
41
41
  // Consumer items bypass this map entirely via their own `run` closure.
42
42
  const actions: Record<NavActionId, () => void> = {
43
+ assistant: () => ui.openAssistant(),
43
44
  buildPipeline: () => ui.openBuilder(),
44
45
  addFromRepo: () => ui.openAddService(),
45
46
  bootstrapRepo: () => ui.openBootstrap(),
@@ -440,6 +440,8 @@ const REASON_DESCRIPTION_KEYS: Record<UnavailableReason | BootstrapReferenceReas
440
440
  service_catalog_filter_missing: 'errors.unavailable.description.service_catalog_filter_missing',
441
441
  service_catalog_response_too_large:
442
442
  'errors.unavailable.description.service_catalog_response_too_large',
443
+ assistant_generation_failed: 'errors.unavailable.description.assistant_generation_failed',
444
+ assistant_reply_unreadable: 'errors.unavailable.description.assistant_reply_unreadable',
443
445
  }
444
446
 
445
447
  /**
@@ -219,6 +219,8 @@ describe('navSlotFilter', () => {
219
219
  // surface has quietly stopped being simple. The table is the claim; adding `intake: true`
220
220
  // fails here until the reason is written down.
221
221
  const REASON: Record<string, string> = {
222
+ assistant:
223
+ 'the shortest route to the two things this role is here to do (put a repository on the board, file a task from a ticket) and it configures nothing',
222
224
  tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
223
225
  'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
224
226
  'ui-role': 'the way BACK out of the narrowed role',
@@ -196,6 +196,7 @@ export interface NavCommandSpec {
196
196
  * dead button. Consumer modules don't use these — they carry their own `run`.
197
197
  */
198
198
  export const NAV_ACTIONS = [
199
+ 'assistant',
199
200
  'buildPipeline',
200
201
  'addFromRepo',
201
202
  'bootstrapRepo',
@@ -341,6 +342,27 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
341
342
  * the whole sidebar costs it nothing it is there to do.
342
343
  */
343
344
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
345
+ {
346
+ // The in-app assistant. `intake: true` and not `advanced`, because it is the shortest route
347
+ // to the two things a narrowed role is on the board to do (put a repository on it, file a
348
+ // task from a ticket) and it configures nothing: every action behind it is a board write the
349
+ // member tier already allows, refused server-side for anyone it should not be.
350
+ id: 'assistant',
351
+ labelKey: 'nav.assistant',
352
+ icon: 'i-lucide-sparkles',
353
+ surfaces: S('sidebar', 'command'),
354
+ gate: (g) => g.canWriteBoard,
355
+ action: 'assistant',
356
+ intake: true,
357
+ testId: 'nav-assistant',
358
+ sidebar: { group: 'create', order: 5 },
359
+ command: {
360
+ group: 'create',
361
+ order: 5,
362
+ labelKey: 'layout.commandBar.cmd.assistant',
363
+ keywordsKey: 'layout.commandBar.keywords.assistant',
364
+ },
365
+ },
344
366
  {
345
367
  id: 'build-pipeline',
346
368
  labelKey: 'nav.buildPipeline',
@@ -48,6 +48,7 @@ const TaskSourceConnectModal = defineAsyncView(
48
48
  )
49
49
  const TaskImportModal = defineAsyncView(() => import('~/components/tasks/TaskImportModal.vue'))
50
50
  const BugHuntModal = defineAsyncView(() => import('~/components/tasks/BugHuntModal.vue'))
51
+ const AssistantModal = defineAsyncView(() => import('~/components/assistant/AssistantModal.vue'))
51
52
  const RecurringPipelineModal = defineAsyncView(
52
53
  () => import('~/components/board/RecurringPipelineModal.vue'),
53
54
  )
@@ -501,6 +502,7 @@ watch(
501
502
  <TaskSourceConnectModal v-if="ui.taskConnect" />
502
503
  <TaskImportModal v-if="ui.taskImport" />
503
504
  <BugHuntModal v-if="ui.bugHunt" />
505
+ <AssistantModal v-if="ui.assistantOpen" />
504
506
  <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
505
507
  <ObservabilityPanel v-if="ui.observabilityInstanceId" />
506
508
  <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
@@ -0,0 +1,69 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AssistantAnswer, AssistantCapability, AssistantTurn } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * In-app assistant state: what this deployment's assistant can do, and the last turn's outcome.
8
+ *
9
+ * Nothing is persisted server-side and nothing accumulates here: a turn is one prompt and one
10
+ * outcome, and the board itself is where the result lives afterwards (the write arrives over the
11
+ * live stream like any other, so the frame or task shows up without this store touching the board).
12
+ * Keeping a transcript would be a second, staler record of changes the board already carries.
13
+ *
14
+ * Failures are NOT held here: every refusal a turn can raise is a `DomainError` the shared error
15
+ * funnel (`usePipelineErrorToast`) already renders translated, copyable and with its request id.
16
+ * The three OUTCOMES are the ones this store keeps, because they are answers rather than errors.
17
+ */
18
+ export const useAssistantStore = defineStore('assistant', () => {
19
+ const api = useApi()
20
+ const workspace = useWorkspaceStore()
21
+
22
+ const capability = ref<AssistantCapability | null>(null)
23
+ const turn = ref<AssistantTurn | null>(null)
24
+ const running = ref(false)
25
+
26
+ /** Whether a model is wired at all; unknown (not yet read) reads as unavailable. */
27
+ const available = computed(() => capability.value?.available === true)
28
+ const actions = computed(() => capability.value?.actions ?? [])
29
+
30
+ /** Read what the assistant can do here. Idempotent: re-reading replaces the answer. */
31
+ async function loadCapability(): Promise<void> {
32
+ capability.value = await api.getAssistantCapability(workspace.requireId())
33
+ }
34
+
35
+ /**
36
+ * Run one turn. Throws on a refusal so the caller can hand it to the error funnel; the three
37
+ * outcomes (performed / needs_input / declined) come back as the resolved value.
38
+ */
39
+ async function run(prompt: string): Promise<AssistantTurn> {
40
+ return record(() => api.runAssistantTurn(workspace.requireId(), prompt))
41
+ }
42
+
43
+ /**
44
+ * Answer the question the last turn asked, by re-running its action with the chosen value in the
45
+ * field it named. Same outcomes, same funnel, and no model call.
46
+ */
47
+ async function answer(chosen: AssistantAnswer): Promise<AssistantTurn> {
48
+ return record(() => api.answerAssistantTurn(workspace.requireId(), chosen))
49
+ }
50
+
51
+ /** The half both turns share: hold `running`, keep the outcome, let a refusal through. */
52
+ async function record(send: () => Promise<AssistantTurn>): Promise<AssistantTurn> {
53
+ running.value = true
54
+ try {
55
+ const result = await send()
56
+ turn.value = result
57
+ return result
58
+ } finally {
59
+ running.value = false
60
+ }
61
+ }
62
+
63
+ /** Forget the last outcome (the modal closing, or a fresh prompt being typed). */
64
+ function reset(): void {
65
+ turn.value = null
66
+ }
67
+
68
+ return { capability, turn, running, available, actions, loadCapability, run, answer, reset }
69
+ })
@@ -216,6 +216,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
216
216
  // the create-in target AND scopes the issue search to the frame's linked repo.
217
217
  // Null → the unscoped "import an issue" surface (workspace-wide search).
218
218
  const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
219
+ // In-app assistant: the prompt box that routes one sentence to one board action. It carries no
220
+ // subject (a turn resolves every name it needs from the board itself), so a plain flag says
221
+ // everything the host needs to know.
222
+ const assistantOpen = ref(false)
223
+
219
224
  // Bug hunt: pick a tracker + one of its boards, rank its open unassigned bugs, adopt one.
220
225
  // `containerId` (a service frame or module) preselects where an adopted bug lands; null →
221
226
  // opened standalone, and the modal offers every container on the board.
@@ -293,6 +298,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
293
298
  function closeTaskImport() {
294
299
  taskImport.value = null
295
300
  }
301
+ function openAssistant() {
302
+ resetHubReturn()
303
+ assistantOpen.value = true
304
+ }
305
+ function closeAssistant() {
306
+ assistantOpen.value = false
307
+ }
296
308
  function openBugHunt(source: TaskSourceKind | null = null, containerId: string | null = null) {
297
309
  resetHubReturn()
298
310
  bugHunt.value = { source, containerId }
@@ -341,6 +353,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
341
353
  spawnPreview,
342
354
  taskConnect,
343
355
  taskImport,
356
+ assistantOpen,
344
357
  bugHunt,
345
358
  startFromDesign,
346
359
  addTaskContainerId,
@@ -360,6 +373,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
360
373
  closeTaskConnect,
361
374
  openTaskImport,
362
375
  closeTaskImport,
376
+ openAssistant,
377
+ closeAssistant,
363
378
  openBugHunt,
364
379
  closeBugHunt,
365
380
  openStartFromDesign,
@@ -0,0 +1,24 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The in-app assistant: one natural-language prompt, one action the platform performs on the
3
+ // person's behalf (declare a service dependency, add a service from a repository URL, file a task
4
+ // from a tracker issue).
5
+ //
6
+ // A turn answers with DATA, never with model prose: the outcome variant carries the ids and names
7
+ // of what was touched, or the machine-readable reason it could not act, and the SPA renders every
8
+ // sentence from those members through the i18n catalog. All wire shapes are sourced from
9
+ // @cat-factory/contracts (single source of truth).
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export type {
13
+ AssistantActionId,
14
+ AssistantActionResult,
15
+ AssistantAnswer,
16
+ AssistantArguments,
17
+ AssistantCapability,
18
+ AssistantClarificationReason,
19
+ AssistantDeclineReason,
20
+ AssistantOutcome,
21
+ AssistantServiceRef,
22
+ AssistantTurn,
23
+ AssistantTurnInput,
24
+ } from '@cat-factory/contracts'
@@ -220,6 +220,7 @@ export type * from './skills'
220
220
  export type * from './foundationalServices'
221
221
  export type * from './documents'
222
222
  export type * from './tasks'
223
+ export type * from './assistant'
223
224
  export type * from './bugHunt'
224
225
  export type * from './bootstrap'
225
226
  export type * from './envConfigRepair'