@cat-factory/app 0.70.0 → 0.71.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.
Files changed (69) hide show
  1. package/app/components/auth/LoginScreen.vue +3 -2
  2. package/app/components/board/AddTaskModal.vue +7 -1
  3. package/app/components/board/AgentFailureCard.vue +3 -0
  4. package/app/components/board/RecurringPipelineModal.vue +6 -1
  5. package/app/components/board/nodes/BlockNode.vue +10 -2
  6. package/app/components/board/nodes/TaskCard.vue +12 -1
  7. package/app/components/bootstrap/BootstrapModal.vue +3 -0
  8. package/app/components/common/ConfirmDialog.vue +59 -0
  9. package/app/components/common/EmptyState.vue +35 -0
  10. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  11. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  12. package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
  13. package/app/components/focus/BlockFocusView.vue +12 -7
  14. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  15. package/app/components/github/GitHubPanel.vue +9 -0
  16. package/app/components/humanTest/HumanTestWindow.vue +15 -1
  17. package/app/components/layout/AccountDeploymentSettings.vue +4 -0
  18. package/app/components/layout/AccountTeamSettings.vue +8 -2
  19. package/app/components/layout/AiProvidersBanner.vue +3 -1
  20. package/app/components/layout/BoardSwitcher.vue +11 -0
  21. package/app/components/layout/CommandBar.vue +8 -0
  22. package/app/components/layout/InfraSetupBanner.vue +192 -0
  23. package/app/components/layout/NotificationsInbox.vue +11 -0
  24. package/app/components/layout/ProviderConfigBanner.vue +3 -1
  25. package/app/components/panels/InspectorPanel.vue +18 -20
  26. package/app/components/panels/StepContainerStatus.vue +37 -0
  27. package/app/components/panels/inspector/FrontendConfig.vue +19 -4
  28. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
  29. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  30. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  31. package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
  32. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  33. package/app/components/providers/ApiKeysSection.vue +4 -0
  34. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  35. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  36. package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
  37. package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
  38. package/app/components/settings/KubernetesEngineForm.vue +41 -5
  39. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  40. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  41. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  42. package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
  43. package/app/components/settings/ProviderConnectionTab.vue +2 -0
  44. package/app/components/settings/UserSecretsSection.vue +11 -0
  45. package/app/components/slack/SlackPanel.vue +9 -0
  46. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  47. package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
  48. package/app/composables/useBlockDeletion.ts +63 -0
  49. package/app/composables/useConfirm.ts +63 -0
  50. package/app/composables/useConfirmAction.ts +96 -0
  51. package/app/composables/useKeyboardShortcuts.ts +76 -0
  52. package/app/composables/usePipelineErrorToast.ts +2 -0
  53. package/app/composables/useWorkspaceStream.ts +26 -6
  54. package/app/pages/index.vue +28 -4
  55. package/app/stores/agentRuns.spec.ts +74 -0
  56. package/app/stores/agentRuns.ts +33 -5
  57. package/app/stores/ui.ts +33 -1
  58. package/app/stores/workspace.ts +10 -2
  59. package/app/types/domain.ts +3 -0
  60. package/app/utils/pipeline.ts +22 -0
  61. package/i18n/locales/en.json +174 -12
  62. package/i18n/locales/es.json +176 -17
  63. package/i18n/locales/fr.json +176 -17
  64. package/i18n/locales/he.json +176 -17
  65. package/i18n/locales/ja.json +176 -17
  66. package/i18n/locales/pl.json +176 -17
  67. package/i18n/locales/tr.json +176 -17
  68. package/i18n/locales/uk.json +176 -17
  69. package/package.json +3 -2
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Confirm-gate + success-toast for the recurring destructive actions that aren't board
3
+ * blocks (disconnect a connection, remove/revoke a credential, clear a config, destroy an
4
+ * environment). Built on the same `useConfirm()` singleton + `useToast()` the board delete
5
+ * path uses, so every destructive affordance across the settings/connection surfaces routes
6
+ * through ONE confirm-then-mutate + toast path rather than each re-inventing its own copy.
7
+ *
8
+ * A call site becomes:
9
+ *
10
+ * const { confirmAction, toastDone } = useConfirmAction()
11
+ * async function disconnect() {
12
+ * if (!(await confirmAction('disconnect', providerName))) return
13
+ * await store.remove()
14
+ * toastDone('disconnect', providerName)
15
+ * }
16
+ *
17
+ * The copy is generic (`common.confirm.*` / `common.toast.*`) with the target's name
18
+ * interpolated, so the irreversibility warning is translated once per locale — not re-worded
19
+ * per surface. `name` is a short noun for the target (a brand like "Slack", a data value like
20
+ * the invite email, or a feature noun like "the test environment").
21
+ */
22
+ type ConfirmShape = 'disconnect' | 'remove' | 'revoke' | 'clear' | 'destroy'
23
+
24
+ /**
25
+ * Per-shape copy + iconography. An exhaustive `Record<ConfirmShape, …>` (not an inline
26
+ * template key) so adding a shape without wiring its copy is a typecheck failure — the
27
+ * sanctioned guard for enum→message-key lookups the tier-1 typed-keys check can't see.
28
+ */
29
+ const SHAPE_META: Record<
30
+ ConfirmShape,
31
+ { titleKey: string; bodyKey: string; labelKey: string; icon: string; toastKey: string }
32
+ > = {
33
+ disconnect: {
34
+ titleKey: 'common.confirm.titles.disconnect',
35
+ bodyKey: 'common.confirm.reconnectHint',
36
+ labelKey: 'common.disconnect',
37
+ icon: 'i-lucide-unplug',
38
+ toastKey: 'common.toast.disconnected',
39
+ },
40
+ remove: {
41
+ titleKey: 'common.confirm.titles.remove',
42
+ bodyKey: 'common.confirm.irreversible',
43
+ labelKey: 'common.remove',
44
+ icon: 'i-lucide-trash-2',
45
+ toastKey: 'common.toast.removed',
46
+ },
47
+ revoke: {
48
+ titleKey: 'common.confirm.titles.revoke',
49
+ bodyKey: 'common.confirm.irreversible',
50
+ labelKey: 'common.revoke',
51
+ icon: 'i-lucide-ban',
52
+ toastKey: 'common.toast.revoked',
53
+ },
54
+ clear: {
55
+ titleKey: 'common.confirm.titles.clear',
56
+ // A cleared config/connection is re-enterable, so warn it must be reconfigured — not
57
+ // the harsher `irreversible` copy the remove/revoke/destroy shapes use.
58
+ bodyKey: 'common.confirm.reconfigureHint',
59
+ labelKey: 'common.clear',
60
+ icon: 'i-lucide-eraser',
61
+ toastKey: 'common.toast.cleared',
62
+ },
63
+ destroy: {
64
+ titleKey: 'common.confirm.titles.destroy',
65
+ bodyKey: 'common.confirm.irreversible',
66
+ labelKey: 'common.destroy',
67
+ icon: 'i-lucide-trash-2',
68
+ toastKey: 'common.toast.destroyed',
69
+ },
70
+ }
71
+
72
+ export function useConfirmAction() {
73
+ const { confirm } = useConfirm()
74
+ const toast = useToast()
75
+ const { t } = useI18n()
76
+
77
+ /** Prompt before a destructive action against `name`. Resolves `true` only if confirmed. */
78
+ async function confirmAction(shape: ConfirmShape, name: string): Promise<boolean> {
79
+ const meta = SHAPE_META[shape]
80
+ return confirm({
81
+ title: t(meta.titleKey, { name }),
82
+ description: t(meta.bodyKey),
83
+ variant: 'destructive',
84
+ confirmLabel: t(meta.labelKey),
85
+ icon: meta.icon,
86
+ })
87
+ }
88
+
89
+ /** Toast the completed destructive action (call only on real success). */
90
+ function toastDone(shape: ConfirmShape, name: string): void {
91
+ const meta = SHAPE_META[shape]
92
+ toast.add({ title: t(meta.toastKey, { name }), color: 'success', icon: 'i-lucide-check' })
93
+ }
94
+
95
+ return { confirmAction, toastDone }
96
+ }
@@ -0,0 +1,76 @@
1
+ import { onBeforeUnmount, onMounted } from 'vue'
2
+
3
+ /**
4
+ * The app-wide keyboard shortcuts, registered ONCE from `pages/index.vue` (mirroring the
5
+ * ⌘K handler in `CommandBar.vue`). Keeping a single global listener — rather than one per
6
+ * component — is what stops N handlers each firing a delete.
7
+ *
8
+ * · Escape — deselect the current block / close the inspector, but ONLY when no modal
9
+ * is open (every modal is a `UModal` with `role="dialog"`, which already
10
+ * handles its own Escape, so we must not also steal it).
11
+ * · Delete — delete the selected block, through the SAME confirm-gated path the
12
+ * inspector button uses (`useBlockDeletion`). Guarded so it never fires
13
+ * while the user is typing in a field. NOTE: only `Delete`, deliberately
14
+ * NOT `Backspace` — `Backspace` collides with "navigate back" muscle memory
15
+ * and would delete the selected block from anywhere on the board.
16
+ * · ? — toggle the keyboard-shortcuts cheatsheet.
17
+ */
18
+ export function useKeyboardShortcuts(): void {
19
+ const ui = useUiStore()
20
+ const board = useBoardStore()
21
+ const { deleteBlock } = useBlockDeletion()
22
+
23
+ /** A modal (UModal) is on screen — let it own the keyboard; don't run global shortcuts. */
24
+ function modalOpen(): boolean {
25
+ return ui.commandBarOpen || !!document.querySelector('[role="dialog"]')
26
+ }
27
+
28
+ /** The event originates from a text field, so printable/Delete keys are edits, not shortcuts. */
29
+ function isEditableTarget(e: KeyboardEvent): boolean {
30
+ const el = e.target as HTMLElement | null
31
+ if (!el) return false
32
+ const tag = el.tagName
33
+ return (
34
+ tag === 'INPUT' ||
35
+ tag === 'TEXTAREA' ||
36
+ tag === 'SELECT' ||
37
+ el.isContentEditable ||
38
+ !!el.closest('input, textarea, select, [contenteditable="true"]')
39
+ )
40
+ }
41
+
42
+ function onKeydown(e: KeyboardEvent) {
43
+ // "?" — toggle the cheatsheet. Shift+/ on most layouts; guard against typing. The
44
+ // cheatsheet is itself a modal, so allow the toggle to close it (a plain `modalOpen()`
45
+ // guard would trap it open, since "?" could then only ever open, never close).
46
+ if (e.key === '?' && !isEditableTarget(e) && (!modalOpen() || ui.shortcutsHelpOpen)) {
47
+ e.preventDefault()
48
+ ui.toggleShortcutsHelp()
49
+ return
50
+ }
51
+
52
+ if (e.key === 'Escape') {
53
+ // A modal owns Escape (it closes itself); only deselect when nothing is open.
54
+ if (modalOpen()) return
55
+ if (ui.selectedBlockId) {
56
+ e.preventDefault()
57
+ ui.select(null)
58
+ }
59
+ return
60
+ }
61
+
62
+ if (e.key === 'Delete') {
63
+ // Never destroy a block while the user is editing a title/description/query.
64
+ if (isEditableTarget(e) || modalOpen()) return
65
+ const id = ui.selectedBlockId
66
+ if (!id) return
67
+ const block = board.getBlock(id)
68
+ if (!block) return
69
+ e.preventDefault()
70
+ void deleteBlock(block)
71
+ }
72
+ }
73
+
74
+ onMounted(() => window.addEventListener('keydown', onKeydown))
75
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
76
+ }
@@ -46,6 +46,8 @@ const CONFLICT_TITLE_KEYS: Record<
46
46
  bootstrap_not_retryable: 'errors.conflict.title.bootstrap_not_retryable',
47
47
  bootstrap_reference_missing: 'errors.conflict.title.bootstrap_reference_missing',
48
48
  provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
49
+ preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
50
+ visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
49
51
  }
50
52
 
51
53
  /**
@@ -133,12 +133,32 @@ export function useWorkspaceStream() {
133
133
 
134
134
  socket.onopen = () => {
135
135
  attempt = 0
136
- connected.value = true
137
- // Resync on (re)connect: any event missed while disconnected is reconciled.
138
- // The snapshot carries `bootstrapJobs` + executions, so one refresh rehydrates
139
- // agentRuns too — a missed terminal event (e.g. a container eviction that
140
- // failed the run) can't leave a frame stuck on a stale "bootstrapping…" badge.
141
- void workspace.refresh()
136
+ // Resync on (re)connect BEFORE announcing `connected`: any event missed while
137
+ // disconnected is reconciled first. The snapshot carries `bootstrapJobs` +
138
+ // executions, so one refresh rehydrates agentRuns too — a missed terminal event
139
+ // (e.g. a container eviction that failed the run) can't leave a frame stuck on a
140
+ // stale "bootstrapping…" badge.
141
+ //
142
+ // We flip `connected` only AFTER that refresh settles so it means "connected AND
143
+ // reconciled". Otherwise `board.hydrate`/`agentRuns.hydrate` reconcile with a
144
+ // snapshot fetched at connect time, which — under load — can resolve AFTER a fresh
145
+ // live event and clobber it: e.g. `board.hydrate` REPLACES the block list and drops
146
+ // a just-created provisional bootstrap frame the stale snapshot never saw, so its
147
+ // live "bootstrapping…" badge flickers out with no further board event to restore
148
+ // it. Anything acting on a `connected` board (a user, or an e2e spec gating on
149
+ // `data-connected`) then does so only after this reconcile, so a lagging resync
150
+ // can't drop the state that action produces. `connected` is still set on failure
151
+ // (we ARE connected; a transient refresh error must not wedge the indicator/tests).
152
+ void workspace
153
+ .refresh()
154
+ .catch(() => {})
155
+ .finally(() => {
156
+ // A workspace switch (or stop()) may have happened while the refresh was in
157
+ // flight — don't announce a connection for a socket we've since abandoned.
158
+ if (!stopped && socket && workspace.workspaceId === workspaceId) {
159
+ connected.value = true
160
+ }
161
+ })
142
162
  }
143
163
  socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
144
164
  socket.onclose = () => {
@@ -7,6 +7,7 @@ import TranslationWarningBanner from '~/components/layout/TranslationWarningBann
7
7
  import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
8
8
  import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
9
9
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
10
+ import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
10
11
  // Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
11
12
  // store-driven so they must react from anywhere — kept eager for snappy open/close).
12
13
  import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
@@ -18,6 +19,8 @@ import AddTaskModal from '~/components/board/AddTaskModal.vue'
18
19
  import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
19
20
  import CommandBar from '~/components/layout/CommandBar.vue'
20
21
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
22
+ import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
23
+ import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue'
21
24
 
22
25
  // Heavy, rarely-open panels — code-split into their own chunks via defineAsyncComponent
23
26
  // and mounted only while their ui open-flag is set (the v-if gates in the template), so
@@ -113,6 +116,10 @@ const models = useModelsStore()
113
116
  const ui = useUiStore()
114
117
  const aiReadiness = useAiReadiness()
115
118
 
119
+ // App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
120
+ // for the cheatsheet). Registered ONCE here so a single global listener owns them.
121
+ useKeyboardShortcuts()
122
+
116
123
  // Load the board from the backend before rendering it.
117
124
  onMounted(() => {
118
125
  void workspace.init()
@@ -141,6 +148,8 @@ watch(
141
148
  autoOpenedSetup.value = false
142
149
  autoOpenedPreset.value = false
143
150
  ui.resetAiOnboarding()
151
+ // Infra-setup banner session dismissals are per-workspace too — clear them on switch.
152
+ ui.resetInfraSetupDismissals()
144
153
  // A different board has its own pipeline library, so re-arm the once-per-session advisory.
145
154
  ui.pipelineHealthSeen = false
146
155
  }
@@ -242,10 +251,23 @@ watch(
242
251
  <TranslationWarningBanner />
243
252
  <!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
244
253
  <GitHubPatBanner />
245
- <!-- AI-readiness prompt (no usable model source, or default preset uses unavailable models). -->
246
- <AiProvidersBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
247
- <!-- Infrastructure provider prompt (env/runner-pool wired but missing mandatory config). -->
248
- <ProviderConfigBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
254
+ <!-- Stacked advisory banners: one click-through column so concurrent prompts never draw on
255
+ top of each other (a fresh, unconfigured deployment can raise all three at once — no AI
256
+ model + no runner pool + no storage). The wrapper is `pointer-events-none`; each banner
257
+ re-enables pointer events on its own card, so the empty strip never intercepts clicks on
258
+ the board chrome underneath.
259
+ - AI-readiness (no usable model source, or default preset uses unavailable models).
260
+ - Infrastructure provider (env/runner-pool wired but missing mandatory config).
261
+ - Infra-setup (this deployment needs an executor / test env / storage the operator hasn't
262
+ defined yet, so a class of agents can't run). -->
263
+ <div
264
+ v-if="workspace.ready && !needsGitHubInstall && !githubProbePending"
265
+ class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-4"
266
+ >
267
+ <AiProvidersBanner />
268
+ <ProviderConfigBanner />
269
+ <InfraSetupBanner />
270
+ </div>
249
271
 
250
272
  <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
251
273
  <div
@@ -307,6 +329,8 @@ watch(
307
329
  <AddTaskModal />
308
330
  <CommandBar />
309
331
  <PersonalCredentialModal />
332
+ <ConfirmDialog />
333
+ <KeyboardShortcutsHelp />
310
334
 
311
335
  <!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
312
336
  first open (its own chunk) rather than bloating the initial bundle. -->
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import type { BootstrapJob } from '~/types/domain'
3
+ import { useAgentRunsStore } from '~/stores/agentRuns'
4
+
5
+ /** Minimal BootstrapJob factory — only the fields the store's reconcile logic touches. */
6
+ function job(id: string, over: Partial<BootstrapJob> = {}): BootstrapJob {
7
+ return {
8
+ id,
9
+ workspaceId: 'ws_test',
10
+ referenceArchitectureId: null,
11
+ referenceArchitectureName: null,
12
+ repoName: id,
13
+ repoOwner: null,
14
+ repoUrl: null,
15
+ instructions: '',
16
+ status: 'running',
17
+ blockId: `blk_${id}`,
18
+ subtasks: null,
19
+ error: null,
20
+ failure: null,
21
+ createdAt: 1,
22
+ updatedAt: 1,
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ describe('agentRuns store — monotonic bootstrap reconcile', () => {
28
+ let store: ReturnType<typeof useAgentRunsStore>
29
+ beforeEach(() => {
30
+ store = useAgentRunsStore()
31
+ })
32
+
33
+ it('hydrate does NOT regress a run a newer live event already advanced', () => {
34
+ // A live `bootstrap: failed` event landed first (newer updatedAt).
35
+ store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
36
+ // A lagging `workspace.refresh()` then hydrates a STALE snapshot that still saw
37
+ // the run as `running` (older updatedAt) — it must NOT clobber the terminal state.
38
+ store.hydrate([job('j1', { status: 'running', updatedAt: 2 })], 'ws_test')
39
+ expect(store.bootstrapJobs[0]!.status).toBe('failed')
40
+ expect(store.byBlock.blk_j1!.status).toBe('failed')
41
+ })
42
+
43
+ it('hydrate does NOT drop a live-added run the stale snapshot never saw', () => {
44
+ // The on-connect resync fires a snapshot fetch BEFORE the bootstrap starts, then a live
45
+ // `bootstrap: failed` event lands while that fetch is in flight. When the (older) snapshot
46
+ // finally resolves it does not contain the run at all — mapping over the snapshot alone
47
+ // would silently drop it, stranding the frame with no further event to correct it.
48
+ store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
49
+ store.hydrate([], 'ws_test') // stale snapshot: fetched before the run existed
50
+ expect(store.bootstrapJobs[0]?.status).toBe('failed')
51
+ expect(store.byBlock.blk_j1!.status).toBe('failed')
52
+ })
53
+
54
+ it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
55
+ store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
56
+ // Switching to ws_test: its snapshot must not leak the previous board's run.
57
+ store.hydrate([job('j2', { workspaceId: 'ws_test' })], 'ws_test')
58
+ expect(store.bootstrapJobs.map((j) => j.id)).toEqual(['j2'])
59
+ })
60
+
61
+ it('hydrate DOES apply a genuinely newer snapshot', () => {
62
+ store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 2 }))
63
+ store.hydrate([job('j1', { status: 'succeeded', updatedAt: 9 })], 'ws_test')
64
+ expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
65
+ })
66
+
67
+ it('upsertBootstrap ignores an older/out-of-order event but applies newer/equal', () => {
68
+ store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
69
+ store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 3 })) // stale → ignored
70
+ expect(store.bootstrapJobs[0]!.status).toBe('failed')
71
+ store.upsertBootstrap(job('j1', { status: 'succeeded', updatedAt: 5 })) // equal → applied
72
+ expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
73
+ })
74
+ })
@@ -59,9 +59,34 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
59
59
  */
60
60
  const envConfigRepairJobs = ref<EnvConfigRepairJob[]>([])
61
61
 
62
- /** Replace the cached bootstrap runs with a server snapshot. */
63
- function hydrate(jobs: BootstrapJob[]) {
64
- bootstrapJobs.value = [...jobs].sort((a, b) => b.createdAt - a.createdAt)
62
+ /**
63
+ * Reconcile the cached bootstrap runs with a server snapshot for `workspaceId`. A snapshot is
64
+ * authoritative EXCEPT where a live event has already advanced (or ADDED) a run past what this
65
+ * (possibly stale) read observed: a `board` event triggers a debounced `workspace.refresh()`,
66
+ * and the stream's on-(re)connect resync also refetches — either read can resolve AFTER a newer
67
+ * `bootstrap` event already landed. Two clobber hazards, both handled here:
68
+ * - REGRESS: a run present in BOTH the snapshot and the cache — keep the newer-by-`updatedAt`
69
+ * version so a lagging refresh can't revert a `failed`/`succeeded` run to `running`.
70
+ * - DROP: a run a live event just ADDED that the (older) snapshot never saw — mapping over the
71
+ * snapshot alone would silently drop it, and a terminal bootstrap emits nothing further, so
72
+ * the frame would be stranded on a stale "bootstrapping…" badge with no event to correct it.
73
+ * Preserve such cached runs. Scoped to `workspaceId` (bootstrap runs carry it), so a board
74
+ * SWITCH — whose snapshot is for a different workspace — still discards the previous board's
75
+ * runs instead of leaking them onto the new board.
76
+ */
77
+ function hydrate(jobs: BootstrapJob[], workspaceId: string) {
78
+ const incomingIds = new Set(jobs.map((j) => j.id))
79
+ const held = new Map(bootstrapJobs.value.map((j) => [j.id, j]))
80
+ const reconciled = jobs.map((incoming) => {
81
+ const current = held.get(incoming.id)
82
+ return current && current.updatedAt > incoming.updatedAt ? current : incoming
83
+ })
84
+ // Live-added runs the snapshot hasn't observed yet — keep only this workspace's (a switch
85
+ // starts clean), so a resync that races a fresh `bootstrap` event can't drop the run.
86
+ const preserved = [...held.values()].filter(
87
+ (j) => !incomingIds.has(j.id) && j.workspaceId === workspaceId,
88
+ )
89
+ bootstrapJobs.value = [...reconciled, ...preserved].sort((a, b) => b.createdAt - a.createdAt)
65
90
  }
66
91
 
67
92
  /** Replace the cached env-config-repair runs with a server snapshot. */
@@ -92,8 +117,11 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
92
117
  */
93
118
  function upsertBootstrap(job: BootstrapJob) {
94
119
  const i = bootstrapJobs.value.findIndex((j) => j.id === job.id)
95
- if (i >= 0) bootstrapJobs.value[i] = job
96
- else bootstrapJobs.value.unshift(job)
120
+ // Monotonic by `updatedAt`: never let a stale/out-of-order event regress a run a
121
+ // newer one already advanced (same guard as {@link hydrate}).
122
+ if (i >= 0) {
123
+ if (job.updatedAt >= bootstrapJobs.value[i]!.updatedAt) bootstrapJobs.value[i] = job
124
+ } else bootstrapJobs.value.unshift(job)
97
125
  }
98
126
 
99
127
  /**
package/app/stores/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
- import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
3
+ import type { DocumentSourceKind, TaskSourceKind, LodLevel, InfraSetupArea } from '~/types/domain'
4
4
  import type { PendingContext } from '~/composables/useContextLinking'
5
5
  import { zoomToLod } from '~/composables/useSemanticZoom'
6
6
  import { useExecutionStore } from '~/stores/execution'
@@ -105,6 +105,9 @@ export const useUiStore = defineStore('ui', () => {
105
105
  // Command bar (⌘K) — searchable launcher for every navbar action.
106
106
  const commandBarOpen = ref(false)
107
107
 
108
+ // Keyboard-shortcuts cheatsheet (?) — a modal listing every global shortcut.
109
+ const shortcutsHelpOpen = ref(false)
110
+
108
111
  // Mobile navigation drawer: on compact (< lg) viewports the SideBar is an
109
112
  // off-canvas drawer toggled by a hamburger; on lg+ it is a static aside and this
110
113
  // flag is ignored. Closed on any nav action so the board is revealed immediately.
@@ -187,6 +190,19 @@ export const useUiStore = defineStore('ui', () => {
187
190
  const aiSetupDismissed = ref(false)
188
191
  const aiPresetDismissed = ref(false)
189
192
 
193
+ // Infra-setup banner: per-SESSION dismissals, one flag per area, cleared on workspace switch
194
+ // exactly like the AI-onboarding flags (a dismissal in one workspace must not suppress the
195
+ // independent prompt for another). The PERMANENT "don't notify me again" dismissal is per-USER
196
+ // and persists in localStorage from the banner component; this only covers "hide for now".
197
+ const infraSetupSessionDismissed = ref<InfraSetupArea[]>([])
198
+ function dismissInfraSetupForSession(area: InfraSetupArea) {
199
+ if (!infraSetupSessionDismissed.value.includes(area))
200
+ infraSetupSessionDismissed.value = [...infraSetupSessionDismissed.value, area]
201
+ }
202
+ function resetInfraSetupDismissals() {
203
+ infraSetupSessionDismissed.value = []
204
+ }
205
+
190
206
  // Dedicated result-view overlay: a step whose agent kind declares a bespoke
191
207
  // visualization (via the archetype's `resultView`) opens here instead of the generic
192
208
  // prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
@@ -446,6 +462,15 @@ export const useUiStore = defineStore('ui', () => {
446
462
  function toggleCommandBar() {
447
463
  commandBarOpen.value = !commandBarOpen.value
448
464
  }
465
+ function openShortcutsHelp() {
466
+ shortcutsHelpOpen.value = true
467
+ }
468
+ function closeShortcutsHelp() {
469
+ shortcutsHelpOpen.value = false
470
+ }
471
+ function toggleShortcutsHelp() {
472
+ shortcutsHelpOpen.value = !shortcutsHelpOpen.value
473
+ }
449
474
  function openMobileNav() {
450
475
  mobileNavOpen.value = true
451
476
  }
@@ -754,6 +779,7 @@ export const useUiStore = defineStore('ui', () => {
754
779
  slackOpen,
755
780
  fragmentLibraryOpen,
756
781
  commandBarOpen,
782
+ shortcutsHelpOpen,
757
783
  mobileNavOpen,
758
784
  integrationsOpen,
759
785
  cameFromIntegrations,
@@ -779,6 +805,9 @@ export const useUiStore = defineStore('ui', () => {
779
805
  aiPresetMismatchOpen,
780
806
  aiSetupDismissed,
781
807
  aiPresetDismissed,
808
+ infraSetupSessionDismissed,
809
+ dismissInfraSetupForSession,
810
+ resetInfraSetupDismissals,
782
811
  resultView,
783
812
  closeResultView,
784
813
  stepDetail,
@@ -829,6 +858,9 @@ export const useUiStore = defineStore('ui', () => {
829
858
  openCommandBar,
830
859
  closeCommandBar,
831
860
  toggleCommandBar,
861
+ openShortcutsHelp,
862
+ closeShortcutsHelp,
863
+ toggleShortcutsHelp,
832
864
  openMobileNav,
833
865
  closeMobileNav,
834
866
  toggleMobileNav,
@@ -1,6 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
- import type { SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
3
+ import type { InfraSetup, SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
4
4
  import { useAccountsStore } from '~/stores/accounts'
5
5
  import { useBoardStore } from '~/stores/board'
6
6
  import { usePipelinesStore } from '~/stores/pipelines'
@@ -49,6 +49,12 @@ export const useWorkspaceStore = defineStore(
49
49
  const error = ref<string | null>(null)
50
50
  /** Latest spend-safeguard status from the server (null until first load). */
51
51
  const spend = ref<SpendStatus | null>(null)
52
+ /**
53
+ * Per-area infrastructure-setup status (ephemeral environments / agent executor / binary
54
+ * storage) from the snapshot, driving the infra-setup banner. Null on an older backend that
55
+ * doesn't compute it (⇒ no banner).
56
+ */
57
+ const infraSetup = ref<InfraSetup | null>(null)
52
58
 
53
59
  /** The boards belonging to the active account (all boards when auth is off). */
54
60
  const accountWorkspaces = computed(() => {
@@ -78,6 +84,7 @@ export const useWorkspaceStore = defineStore(
78
84
  }
79
85
  workspaceId.value = snapshot.workspace.id
80
86
  spend.value = snapshot.spend ?? null
87
+ infraSetup.value = snapshot.infraSetup ?? null
81
88
  // Keep the board list in step (e.g. a freshly created board, or a rename).
82
89
  const i = workspaces.value.findIndex((w) => w.id === snapshot.workspace.id)
83
90
  if (i >= 0) workspaces.value[i] = snapshot.workspace
@@ -85,7 +92,7 @@ export const useWorkspaceStore = defineStore(
85
92
  useBoardStore().hydrate(snapshot.blocks)
86
93
  usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
87
94
  useExecutionStore().hydrate(snapshot.executions)
88
- useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [])
95
+ useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
89
96
  useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
90
97
  useNotificationsStore().hydrate(snapshot.notifications ?? [])
91
98
  useMergePresetsStore().hydrate(
@@ -236,6 +243,7 @@ export const useWorkspaceStore = defineStore(
236
243
  ready,
237
244
  error,
238
245
  spend,
246
+ infraSetup,
239
247
  init,
240
248
  switchTo,
241
249
  selectAccount,
@@ -54,6 +54,9 @@ export type {
54
54
  TaskLimitMode,
55
55
  WorkspaceSettings,
56
56
  UpdateWorkspaceSettingsInput,
57
+ InfraSetup,
58
+ InfraSetupStatus,
59
+ InfraSetupArea,
57
60
  ServiceFragmentDefaults,
58
61
  KaizenGradingStatus,
59
62
  KaizenGrading,
@@ -0,0 +1,22 @@
1
+ import { frameAllowsVisualPipeline, pipelineHasVisualStep } from '@cat-factory/contracts'
2
+ import type { Block, Pipeline } from '~/types/domain'
3
+
4
+ // Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
5
+ // (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
6
+ // `frontend` frame, or a frame a `frontend` frame links to. The SPA hides such pipelines from
7
+ // the pickers where they can't run so a user never picks one the backend would refuse. Both
8
+ // sides share the pure predicates from `@cat-factory/contracts`, so the surface can't drift from
9
+ // the gate.
10
+
11
+ /**
12
+ * Whether `pipeline` may run on a task under `frame`. A non-visual pipeline is always allowed;
13
+ * a visual one only when the frame has a UI (see {@link frameAllowsVisualPipeline}). `blocks` is
14
+ * the board's block list, used to find frontend→service links.
15
+ */
16
+ export function pipelineAllowedForFrame(
17
+ pipeline: Pipeline,
18
+ frame: Block | undefined,
19
+ blocks: readonly Block[],
20
+ ): boolean {
21
+ return !pipelineHasVisualStep(pipeline) || frameAllowsVisualPipeline(frame, blocks)
22
+ }