@cat-factory/app 0.114.0 → 0.115.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.
@@ -4,7 +4,7 @@
4
4
  // the unified retry through the agentRuns store, so every surface (board card,
5
5
  // inspector, task panel) gets identical behaviour from one place. Replaces the
6
6
  // three hand-rolled bootstrap banners that used to duplicate this logic.
7
- import type { EnvironmentFailureReason } from '@cat-factory/contracts'
7
+ import type { ConflictReason, EnvironmentFailureReason } from '@cat-factory/contracts'
8
8
  import type { AgentRunSummary } from '~/stores/agentRuns'
9
9
  import FailureDetail from '~/components/board/FailureDetail.vue'
10
10
 
@@ -26,6 +26,10 @@ const failure = computed(() => props.run.failure)
26
26
  // infra-setup banners use.
27
27
  const isEnvironmentFailure = computed(() => failure.value?.kind === 'environment')
28
28
  const DEPLOY_RUNNER_UNWIRED: EnvironmentFailureReason = 'deploy_runner_unwired'
29
+ // A `preflight` rejection with this reason means the run couldn't start because the workspace
30
+ // has no connected/linked repo — the same cause the 409 toast words, so reuse its translated
31
+ // title (and let the backend's actionable message + doc link show in the detail below).
32
+ const GITHUB_NOT_CONNECTED: ConflictReason = 'github_not_connected'
29
33
 
30
34
  // The provision type of the failed run's SERVICE frame (walk up to the frame, mirroring the
31
35
  // backend's `resolveServiceProvisioning`). Drives the K8s-specific gate below.
@@ -69,6 +73,10 @@ const showEnvironmentLocalHint = computed(
69
73
  provisionType.value === 'kubernetes',
70
74
  )
71
75
  const title = computed(() => {
76
+ // A `github_not_connected` preflight rejection (no repo linked/connected) — name it with
77
+ // the same translated title the 409 toast uses, with the actionable remedy in the detail.
78
+ if (failure.value?.reason === GITHUB_NOT_CONNECTED)
79
+ return t('errors.conflict.title.github_not_connected')
72
80
  // A `dispatch` failure means the container/runner never accepted the job — say so
73
81
  // explicitly rather than the generic "Run failed", and show the verbatim provider
74
82
  // error in the collapsible detail below.
@@ -3,14 +3,16 @@
3
3
  // materially different implementation approaches, opened via the universal result-view host
4
4
  // (`ui.openForkDecision`). It reads the live fork state straight off the run's Coder step
5
5
  // (`step.forkDecision`, kept fresh by the execution stream) and lets a human pick a proposed
6
- // fork OR enter their own free-text approach. Once chosen, the Coder re-runs with the chosen
7
- // approach folded in. Chat is added in a later slice.
6
+ // fork OR enter their own free-text approach, or CHAT about the forks before deciding. Once
7
+ // chosen, the Coder re-runs with the chosen approach folded in. Chat replies are computed by an
8
+ // inline grounded LLM in the durable driver and arrive live on the execution stream.
8
9
  import { computed, ref, watch } from 'vue'
10
+ import { DEFAULT_FORK_MAX_CHAT_TURNS } from '@cat-factory/contracts'
9
11
  import { useResultView } from '~/composables/useResultView'
10
12
  import { useExecutionStore } from '~/stores/execution'
11
13
  import { useBoardStore } from '~/stores/board'
12
14
  import { useForkDecisionStore } from '~/stores/forkDecision'
13
- import type { ForkDecisionStepState, ForkOption } from '~/types/execution'
15
+ import type { ForkChatMessage, ForkDecisionStepState, ForkOption } from '~/types/execution'
14
16
  import { FORK_DECISION_META } from '~/utils/catalog'
15
17
 
16
18
  const execution = useExecutionStore()
@@ -36,11 +38,25 @@ const state = computed<ForkDecisionStepState | null>(() => step.value?.forkDecis
36
38
  const status = computed(() => state.value?.status ?? null)
37
39
  const forks = computed<ForkOption[]>(() => state.value?.forks ?? [])
38
40
  const awaiting = computed(() => status.value === 'awaiting_choice')
41
+ // A chat turn is being answered by the inline responder (the reply arrives via the stream).
42
+ const answering = computed(() => status.value === 'answering')
43
+ // The interactive surface (fork cards + chat + choose) is shown while awaiting OR answering.
44
+ const interactive = computed(() => awaiting.value || answering.value)
45
+ const chat = computed<ForkChatMessage[]>(() => state.value?.chat ?? [])
46
+ // The chat has spent its human-turn budget once the human has sent `maxChatTurns` messages.
47
+ const chatBudgetSpent = computed(() => {
48
+ const max = state.value?.maxChatTurns ?? DEFAULT_FORK_MAX_CHAT_TURNS
49
+ return chat.value.filter((m) => m.role === 'human').length >= max
50
+ })
51
+ // `awaiting` and `answering` are mutually exclusive statuses, so awaiting already implies the
52
+ // chat isn't mid-answer — no separate `!answering` guard is needed.
53
+ const canChat = computed(() => awaiting.value && !chatBudgetSpent.value && !forkDecision.chatting)
39
54
 
40
55
  // The human's selection: a proposed fork id, or the sentinel 'custom' for the free-text path.
41
56
  const selected = ref<string | null>(null)
42
57
  const customText = ref('')
43
58
  const note = ref('')
59
+ const chatInput = ref('')
44
60
 
45
61
  // Default the selection to the recommended fork whenever the fork set changes.
46
62
  watch(
@@ -72,6 +88,14 @@ async function onChoose() {
72
88
  : { forkId: selected.value!, note: noteText }
73
89
  await forkDecision.choose(id, choice).catch(() => {})
74
90
  }
91
+
92
+ async function onSend() {
93
+ const id = instanceId.value
94
+ const text = chatInput.value.trim()
95
+ if (!id || !text || !canChat.value) return
96
+ chatInput.value = ''
97
+ await forkDecision.chat(id, text).catch(() => {})
98
+ }
75
99
  </script>
76
100
 
77
101
  <template>
@@ -157,8 +181,8 @@ async function onChoose() {
157
181
  </p>
158
182
  </div>
159
183
 
160
- <!-- Awaiting the human's choice. -->
161
- <div v-else-if="awaiting" class="space-y-3">
184
+ <!-- Awaiting the human's choice (or answering a chat turn). -->
185
+ <div v-else-if="interactive" class="space-y-3">
162
186
  <p
163
187
  v-if="forkDecision.error"
164
188
  class="rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
@@ -263,6 +287,74 @@ async function onChoose() {
263
287
  class="w-full rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none"
264
288
  />
265
289
  </div>
290
+
291
+ <!-- Grounded chat: ask about the forks before deciding. -->
292
+ <section class="rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-3">
293
+ <p class="text-[11px] font-medium text-slate-400">
294
+ {{ t('forkDecision.chat.title') }}
295
+ </p>
296
+ <div
297
+ v-if="chat.length || answering"
298
+ class="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
299
+ >
300
+ <div
301
+ v-for="msg in chat"
302
+ :key="msg.id"
303
+ data-testid="fork-chat-message"
304
+ class="flex"
305
+ :class="msg.role === 'human' ? 'justify-end' : 'justify-start'"
306
+ >
307
+ <p
308
+ class="max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-1.5 text-[12px]"
309
+ :class="
310
+ msg.role === 'human'
311
+ ? 'bg-violet-500/15 text-violet-100'
312
+ : 'bg-slate-800/70 text-slate-200'
313
+ "
314
+ >
315
+ {{ msg.text }}
316
+ </p>
317
+ </div>
318
+ <div v-if="answering" class="flex justify-start">
319
+ <p
320
+ class="flex items-center gap-1.5 rounded-lg bg-slate-800/70 px-3 py-1.5 text-[12px] text-slate-400"
321
+ >
322
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
323
+ {{ t('forkDecision.chat.thinking') }}
324
+ </p>
325
+ </div>
326
+ </div>
327
+ <p v-else class="mt-1 text-[11px] text-slate-500">
328
+ {{ t('forkDecision.chat.hint') }}
329
+ </p>
330
+ <div class="mt-2 flex items-end gap-2">
331
+ <textarea
332
+ v-model="chatInput"
333
+ data-testid="fork-chat-input"
334
+ rows="2"
335
+ :disabled="!canChat"
336
+ :placeholder="
337
+ chatBudgetSpent
338
+ ? t('forkDecision.chat.budgetSpent')
339
+ : t('forkDecision.chat.placeholder')
340
+ "
341
+ class="min-h-0 flex-1 resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none disabled:opacity-50"
342
+ @keydown.enter.exact.prevent="onSend"
343
+ />
344
+ <UButton
345
+ data-testid="fork-chat-send"
346
+ color="neutral"
347
+ variant="soft"
348
+ size="sm"
349
+ icon="i-lucide-send"
350
+ :loading="forkDecision.chatting"
351
+ :disabled="!canChat || chatInput.trim().length === 0"
352
+ @click="onSend"
353
+ >
354
+ {{ t('forkDecision.chat.send') }}
355
+ </UButton>
356
+ </div>
357
+ </section>
266
358
  </div>
267
359
 
268
360
  <!-- Skipped / no state: nothing to decide. -->
@@ -276,7 +368,7 @@ async function onChoose() {
276
368
  </div>
277
369
 
278
370
  <footer
279
- v-if="awaiting"
371
+ v-if="interactive"
280
372
  class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
281
373
  >
282
374
  <UButton color="neutral" variant="ghost" size="sm" @click="close">
@@ -554,6 +554,8 @@ const ITEM_ICON: Record<string, string> = {
554
554
  <button
555
555
  v-if="forkPhase(s)"
556
556
  type="button"
557
+ data-testid="fork-decision-open"
558
+ :data-fork-phase="forkPhase(s)"
557
559
  class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-start transition hover:border-violet-400/60"
558
560
  :class="
559
561
  forkPhase(s) === 'awaiting_choice'
@@ -1,4 +1,8 @@
1
- import { chooseForkContract, getForkDecisionContract } from '@cat-factory/contracts'
1
+ import {
2
+ chooseForkContract,
3
+ forkChatContract,
4
+ getForkDecisionContract,
5
+ } from '@cat-factory/contracts'
2
6
  import type { ApiContext } from './context'
3
7
 
4
8
  /**
@@ -14,6 +18,14 @@ export function forkDecisionApi({ send, ws }: ApiContext) {
14
18
  getForkDecision: (workspaceId: string, executionId: string) =>
15
19
  send(getForkDecisionContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
16
20
 
21
+ // Send a grounded chat message about the surfaced forks (the reply arrives via the stream).
22
+ forkChat: (workspaceId: string, executionId: string, text: string) =>
23
+ send(forkChatContract, {
24
+ pathPrefix: ws(workspaceId),
25
+ pathParams: { executionId },
26
+ body: { text },
27
+ }),
28
+
17
29
  // Choose an implementation approach — a proposed fork id or a custom approach (+ note).
18
30
  chooseFork: (
19
31
  workspaceId: string,
@@ -20,6 +20,8 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
20
20
 
21
21
  /** True while a choose call is in flight (drives the Choose button spinner / disabled state). */
22
22
  const choosing = ref(false)
23
+ /** True while a chat send is in flight (drives the chat send spinner / disabled state). */
24
+ const chatting = ref(false)
23
25
  /** The last error message from an action, surfaced inline; cleared on the next action. */
24
26
  const error = ref<string | null>(null)
25
27
 
@@ -80,5 +82,25 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
80
82
  }
81
83
  }
82
84
 
83
- return { choosing, error, load, choose }
85
+ /**
86
+ * Send a grounded chat message about the surfaced forks. The reply is computed inline in the
87
+ * durable driver and arrives via the execution stream; the immediate response is the
88
+ * `answering` state (the human message already appended), which we reflect so the thread shows
89
+ * the sent turn + a "thinking…" bubble without waiting for the stream.
90
+ */
91
+ async function chat(executionId: string, text: string): Promise<void> {
92
+ error.value = null
93
+ chatting.value = true
94
+ try {
95
+ const state = await api.forkChat(workspace.requireId(), executionId, text)
96
+ reflect(executionId, state as ForkDecisionStepState)
97
+ } catch (e) {
98
+ error.value = e instanceof Error ? e.message : 'Failed to send message'
99
+ throw e
100
+ } finally {
101
+ chatting.value = false
102
+ }
103
+ }
104
+
105
+ return { choosing, chatting, error, load, choose, chat }
84
106
  })
@@ -4610,6 +4610,14 @@
4610
4610
  "title": "Eigenen Ansatz eingeben",
4611
4611
  "placeholder": "Beschreibe, wie dies umgesetzt werden soll…"
4612
4612
  },
4613
+ "chat": {
4614
+ "title": "Zu diesen Ansätzen nachfragen",
4615
+ "hint": "Unsicher, welchen Sie wählen sollen? Stellen Sie vor der Entscheidung eine Frage.",
4616
+ "thinking": "Denkt nach …",
4617
+ "placeholder": "Fragen Sie nach Kompromissen, Risiken oder einer anderen Richtung …",
4618
+ "budgetSpent": "Chat-Limit erreicht – wählen Sie einen Ansatz oder geben Sie einen eigenen ein.",
4619
+ "send": "Senden"
4620
+ },
4613
4621
  "empty": {
4614
4622
  "title": "Nichts zu entscheiden"
4615
4623
  }
@@ -4727,6 +4727,14 @@
4727
4727
  "title": "Enter your own approach",
4728
4728
  "placeholder": "Describe how you want this implemented…"
4729
4729
  },
4730
+ "chat": {
4731
+ "title": "Ask about these approaches",
4732
+ "hint": "Not sure which to pick? Ask a question before you decide.",
4733
+ "thinking": "Thinking…",
4734
+ "placeholder": "Ask about the trade-offs, risks, or a different direction…",
4735
+ "budgetSpent": "Chat limit reached — pick an approach or enter your own.",
4736
+ "send": "Send"
4737
+ },
4730
4738
  "empty": {
4731
4739
  "title": "Nothing to decide"
4732
4740
  }
@@ -4598,6 +4598,14 @@
4598
4598
  "title": "Introduce tu propio enfoque",
4599
4599
  "placeholder": "Describe cómo quieres que se implemente…"
4600
4600
  },
4601
+ "chat": {
4602
+ "title": "Pregunta sobre estos enfoques",
4603
+ "hint": "¿No sabes cuál elegir? Haz una pregunta antes de decidir.",
4604
+ "thinking": "Pensando…",
4605
+ "placeholder": "Pregunta por las ventajas y desventajas, los riesgos o una dirección distinta…",
4606
+ "budgetSpent": "Límite del chat alcanzado: elige un enfoque o introduce el tuyo.",
4607
+ "send": "Enviar"
4608
+ },
4601
4609
  "empty": {
4602
4610
  "title": "Nada que decidir"
4603
4611
  }
@@ -4598,6 +4598,14 @@
4598
4598
  "title": "Saisir votre propre approche",
4599
4599
  "placeholder": "Décrivez comment vous voulez que ce soit implémenté…"
4600
4600
  },
4601
+ "chat": {
4602
+ "title": "Poser une question sur ces approches",
4603
+ "hint": "Vous hésitez ? Posez une question avant de décider.",
4604
+ "thinking": "Réflexion en cours…",
4605
+ "placeholder": "Interrogez sur les compromis, les risques ou une autre direction…",
4606
+ "budgetSpent": "Limite de discussion atteinte — choisissez une approche ou saisissez la vôtre.",
4607
+ "send": "Envoyer"
4608
+ },
4601
4609
  "empty": {
4602
4610
  "title": "Rien à décider"
4603
4611
  }
@@ -4609,6 +4609,14 @@
4609
4609
  "title": "הזן גישה משלך",
4610
4610
  "placeholder": "תאר כיצד ברצונך שזה ימומש…"
4611
4611
  },
4612
+ "chat": {
4613
+ "title": "שאל על הגישות האלה",
4614
+ "hint": "לא בטוח מה לבחור? שאל שאלה לפני שתחליט.",
4615
+ "thinking": "חושב…",
4616
+ "placeholder": "שאל על היתרונות והחסרונות, הסיכונים או כיוון אחר…",
4617
+ "budgetSpent": "הגעת למגבלת הצ׳אט — בחר גישה או הזן גישה משלך.",
4618
+ "send": "שלח"
4619
+ },
4612
4620
  "empty": {
4613
4621
  "title": "אין מה להחליט"
4614
4622
  }
@@ -4610,6 +4610,14 @@
4610
4610
  "title": "Inserisci il tuo approccio",
4611
4611
  "placeholder": "Descrivi come vuoi che venga implementato…"
4612
4612
  },
4613
+ "chat": {
4614
+ "title": "Fai una domanda su questi approcci",
4615
+ "hint": "Non sai quale scegliere? Fai una domanda prima di decidere.",
4616
+ "thinking": "Sto pensando…",
4617
+ "placeholder": "Chiedi dei compromessi, dei rischi o di una direzione diversa…",
4618
+ "budgetSpent": "Limite della chat raggiunto: scegli un approccio o inserisci il tuo.",
4619
+ "send": "Invia"
4620
+ },
4613
4621
  "empty": {
4614
4622
  "title": "Nulla da decidere"
4615
4623
  }
@@ -4610,6 +4610,14 @@
4610
4610
  "title": "独自のアプローチを入力",
4611
4611
  "placeholder": "どのように実装したいか記述してください…"
4612
4612
  },
4613
+ "chat": {
4614
+ "title": "これらのアプローチについて質問する",
4615
+ "hint": "どれを選ぶか迷っていますか?決める前に質問できます。",
4616
+ "thinking": "考えています…",
4617
+ "placeholder": "トレードオフやリスク、別の方向性について質問してください…",
4618
+ "budgetSpent": "チャットの上限に達しました。アプローチを選ぶか、独自の方法を入力してください。",
4619
+ "send": "送信"
4620
+ },
4613
4621
  "empty": {
4614
4622
  "title": "決定する項目はありません"
4615
4623
  }
@@ -4598,6 +4598,14 @@
4598
4598
  "title": "Wpisz własne podejście",
4599
4599
  "placeholder": "Opisz, jak chcesz to zaimplementować…"
4600
4600
  },
4601
+ "chat": {
4602
+ "title": "Zapytaj o te podejścia",
4603
+ "hint": "Nie wiesz, które wybrać? Zadaj pytanie przed podjęciem decyzji.",
4604
+ "thinking": "Myślę…",
4605
+ "placeholder": "Zapytaj o kompromisy, ryzyko lub inny kierunek…",
4606
+ "budgetSpent": "Osiągnięto limit czatu — wybierz podejście lub wprowadź własne.",
4607
+ "send": "Wyślij"
4608
+ },
4601
4609
  "empty": {
4602
4610
  "title": "Nie ma czego decydować"
4603
4611
  }
@@ -4610,6 +4610,14 @@
4610
4610
  "title": "Kendi yaklaşımınızı girin",
4611
4611
  "placeholder": "Bunun nasıl uygulanmasını istediğinizi açıklayın…"
4612
4612
  },
4613
+ "chat": {
4614
+ "title": "Bu yaklaşımlar hakkında soru sorun",
4615
+ "hint": "Hangisini seçeceğinizden emin değil misiniz? Karar vermeden önce soru sorun.",
4616
+ "thinking": "Düşünüyor…",
4617
+ "placeholder": "Ödünleşimleri, riskleri veya farklı bir yönü sorun…",
4618
+ "budgetSpent": "Sohbet sınırına ulaşıldı — bir yaklaşım seçin veya kendinizinkini girin.",
4619
+ "send": "Gönder"
4620
+ },
4613
4621
  "empty": {
4614
4622
  "title": "Karar verilecek bir şey yok"
4615
4623
  }
@@ -4598,6 +4598,14 @@
4598
4598
  "title": "Введіть власний підхід",
4599
4599
  "placeholder": "Опишіть, як ви хочете це реалізувати…"
4600
4600
  },
4601
+ "chat": {
4602
+ "title": "Запитайте про ці підходи",
4603
+ "hint": "Не впевнені, що обрати? Поставте запитання, перш ніж вирішувати.",
4604
+ "thinking": "Обмірковую…",
4605
+ "placeholder": "Запитайте про компроміси, ризики або інший напрям…",
4606
+ "budgetSpent": "Досягнуто ліміту чату — оберіть підхід або введіть власний.",
4607
+ "send": "Надіслати"
4608
+ },
4601
4609
  "empty": {
4602
4610
  "title": "Нема чого вирішувати"
4603
4611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.114.0",
3
+ "version": "0.115.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.126.0"
37
+ "@cat-factory/contracts": "0.127.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",