@cat-factory/app 0.114.1 → 0.115.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.
@@ -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'
@@ -76,7 +76,12 @@ watch(
76
76
  )
77
77
 
78
78
  onMounted(() => reload())
79
- onBeforeUnmount(stopPolling)
79
+ onBeforeUnmount(() => {
80
+ stopPolling()
81
+ // Drop this run's accumulated log state so the per-execution map doesn't grow for the app's
82
+ // lifetime (a re-opened drawer re-fetches on mount). Subsystem mode keeps its fixed-size state.
83
+ if (props.executionId) store.evict(props.executionId)
84
+ })
80
85
 
81
86
  // Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
82
87
  // live for these runtime-indexed lookups).
@@ -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
  })
@@ -92,4 +92,92 @@ describe('provisioningLogs store — loadForExecution', () => {
92
92
  expect(store.byExecution.exec1!.entries).toHaveLength(0)
93
93
  expect(store.byExecution.exec1!.error).toBe('503')
94
94
  })
95
+
96
+ it('a slower stale load never clobbers a newer one (monotonic guard)', async () => {
97
+ // Two loads race: the FIRST-issued resolves LAST with a stale timeline. Without the guard its
98
+ // `s.entries = entries` would overwrite the fresher second result — a card/row would vanish.
99
+ const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
100
+ vi.stubGlobal('useApi', () => ({
101
+ listProvisioningLogs: () =>
102
+ new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
103
+ }))
104
+
105
+ const store = useProvisioningLogsStore()
106
+ const first = store.loadForExecution('exec1', { silent: true }) // issued #1 (stale)
107
+ const second = store.loadForExecution('exec1', { silent: true }) // issued #2 (fresh)
108
+
109
+ // Resolve NEWEST first, then the older/staler one.
110
+ deferred[1]!({ entries: [entry({ id: 'fresh', operation: 'release' })] })
111
+ deferred[0]!({ entries: [entry({ id: 'stale', operation: 'dispatch' })] })
112
+ await Promise.all([first, second])
113
+
114
+ // The fresher result survives — the stale late-resolver was discarded.
115
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
116
+ expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
117
+ })
118
+
119
+ it('a superseding visible load owns the final entries and clears the spinner', async () => {
120
+ // A visible load in flight, then a newer visible load; the OLDER resolves last and must neither
121
+ // clobber the entries nor leave a stuck spinner.
122
+ const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
123
+ vi.stubGlobal('useApi', () => ({
124
+ listProvisioningLogs: () =>
125
+ new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
126
+ }))
127
+
128
+ const store = useProvisioningLogsStore()
129
+ const first = store.loadForExecution('exec1')
130
+ const second = store.loadForExecution('exec1')
131
+
132
+ deferred[1]!({ entries: [entry({ id: 'fresh' })] })
133
+ deferred[0]!({ entries: [entry({ id: 'stale' })] })
134
+ await Promise.all([first, second])
135
+
136
+ expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
137
+ expect(store.byExecution.exec1!.loading).toBe(false)
138
+ })
139
+
140
+ it("evict drops a run's accumulated state (map does not grow unbounded)", async () => {
141
+ vi.stubGlobal('useApi', () => ({
142
+ listProvisioningLogs: () => Promise.resolve({ entries: [entry()] }),
143
+ }))
144
+
145
+ const store = useProvisioningLogsStore()
146
+ await store.loadForExecution('exec1')
147
+ expect(store.byExecution.exec1).toBeDefined()
148
+
149
+ store.evict('exec1')
150
+ expect(store.byExecution.exec1).toBeUndefined()
151
+
152
+ // After eviction a fresh load re-seeds cleanly (the drawer re-fetches on re-mount) — and the
153
+ // per-execution ticket record was dropped too, so the guard still holds for the new lifecycle.
154
+ await store.loadForExecution('exec1')
155
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
156
+ })
157
+
158
+ it('a load in flight when evict runs cannot resurrect stale state after the drawer re-opens', async () => {
159
+ // Close-then-reopen while the pre-close fetch is still pending: the drawer unmounts (evict), then
160
+ // re-mounts and issues a fresh load, then the OLD pre-evict fetch finally resolves. With a global
161
+ // (never-reset) load ticket the re-opened load out-ranks the straggler, so the stale result is
162
+ // discarded rather than clobbering the fresh timeline (a per-execution counter reset on evict
163
+ // would let the two collide on the same seq and the straggler would win).
164
+ const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
165
+ vi.stubGlobal('useApi', () => ({
166
+ listProvisioningLogs: () =>
167
+ new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
168
+ }))
169
+
170
+ const store = useProvisioningLogsStore()
171
+ const preEvict = store.loadForExecution('exec1', { silent: true }) // issued before close
172
+ store.evict('exec1') // drawer unmounts mid-flight
173
+ const reopened = store.loadForExecution('exec1', { silent: true }) // drawer re-opens, fresh load
174
+
175
+ // The re-opened load resolves first (fresh), then the stale pre-evict straggler.
176
+ deferred[1]!({ entries: [entry({ id: 'fresh', operation: 'release' })] })
177
+ deferred[0]!({ entries: [entry({ id: 'stale', operation: 'dispatch' })] })
178
+ await Promise.all([preEvict, reopened])
179
+
180
+ expect(store.byExecution.exec1!.entries).toHaveLength(1)
181
+ expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
182
+ })
95
183
  })
@@ -30,6 +30,17 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
30
30
  container: emptyState(),
31
31
  })
32
32
  const byExecution = reactive<Record<string, LogState>>({})
33
+ // Monotonic load-ordering guard: the drawer's silent background poll and a manual/visible refresh
34
+ // can be in flight at once, and each ends in a REPLACE-style `s.entries = entries`. Without ordering
35
+ // a slower/staler fetch resolving AFTER a newer one clobbers the fresher timeline (the same
36
+ // out-of-order-overwrite hazard the CLAUDE.md live-push rules warn about, and that
37
+ // stores/workspace.ts guards its full refresh with). Each load takes a ticket from a single
38
+ // ever-increasing counter; `latestLoad` records the newest ticket issued per execution, and only
39
+ // that load commits. The counter is GLOBAL (never reset on `evict`) so a drawer re-opened for an
40
+ // evicted execution always out-ranks any still-in-flight prior load — no seq collision can let a
41
+ // stale fetch win. NOT reactive — pure bookkeeping the UI never reads.
42
+ let loadTicket = 0
43
+ const latestLoad = new Map<string, number>()
33
44
 
34
45
  async function load(subsystem: ProvisioningSubsystem) {
35
46
  const ws = useWorkspaceStore()
@@ -60,23 +71,45 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
60
71
  s.loading = true
61
72
  s.error = null
62
73
  }
74
+ const seq = ++loadTicket
75
+ latestLoad.set(executionId, seq)
63
76
  try {
64
77
  const { entries } = await api.listProvisioningLogs(ws.requireId(), {
65
78
  executionId,
66
79
  limit: 200,
67
80
  })
81
+ // A newer load (silent poll or manual refresh) superseded this one while it was in flight —
82
+ // discard this staler result so it can't clobber the fresher timeline.
83
+ if (latestLoad.get(executionId) !== seq) return
68
84
  s.entries = entries
69
85
  s.error = null
70
86
  } catch (err) {
87
+ if (latestLoad.get(executionId) !== seq) return
71
88
  // A background poll keeps the last snapshot on a blip; only a visible load reports.
72
89
  if (!opts?.silent) {
73
90
  s.error = err instanceof Error ? err.message : 'Failed to load logs'
74
91
  s.entries = []
75
92
  }
76
93
  } finally {
94
+ // The visible spinner is owned by the visible request that turned it on, so clear it in its
95
+ // own finally regardless of supersession — a superseded load still ends its own spinner.
77
96
  if (!opts?.silent) s.loading = false
78
97
  }
79
98
  }
80
99
 
81
- return { bySubsystem, byExecution, load, loadForExecution }
100
+ /**
101
+ * Drop a run's accumulated log state (called by the drawer on unmount). `byExecution` would
102
+ * otherwise accrete one entry per execution viewed and never evict — a slow memory creep across
103
+ * a long board session. The drawer re-fetches on re-mount, so dropping a closed run's state is
104
+ * free; keeping it while OPEN is what the manual-refresh-after-terminal affordance relies on.
105
+ */
106
+ function evict(executionId: string) {
107
+ delete byExecution[executionId]
108
+ // Drop the per-execution ticket record too (its map must not grow unbounded either). The global
109
+ // `loadTicket` counter is deliberately NOT reset, so a re-opened drawer still out-ranks any load
110
+ // that was in flight when this ran.
111
+ latestLoad.delete(executionId)
112
+ }
113
+
114
+ return { bySubsystem, byExecution, load, loadForExecution, evict }
82
115
  })
@@ -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.1",
3
+ "version": "0.115.1",
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",