@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.
- package/app/components/forkDecision/ForkDecisionWindow.vue +98 -6
- package/app/components/pipeline/PipelineProgress.vue +2 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +6 -1
- package/app/composables/api/forkDecision.ts +13 -1
- package/app/stores/forkDecision.ts +23 -1
- package/app/stores/provisioningLogs.spec.ts +88 -0
- package/app/stores/provisioningLogs.ts +34 -1
- package/i18n/locales/de.json +8 -0
- package/i18n/locales/en.json +8 -0
- package/i18n/locales/es.json +8 -0
- package/i18n/locales/fr.json +8 -0
- package/i18n/locales/he.json +8 -0
- package/i18n/locales/it.json +8 -0
- package/i18n/locales/ja.json +8 -0
- package/i18n/locales/pl.json +8 -0
- package/i18n/locales/tr.json +8 -0
- package/i18n/locales/uk.json +8 -0
- package/package.json +2 -2
|
@@ -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
|
|
7
|
-
// approach folded in. Chat
|
|
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="
|
|
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="
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/i18n/locales/de.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/es.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/fr.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/he.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/it.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/ja.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/pl.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/tr.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/uk.json
CHANGED
|
@@ -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.
|
|
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.
|
|
37
|
+
"@cat-factory/contracts": "0.127.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@toad-contracts/testing": "0.3.2",
|