@cat-factory/app 0.70.0 → 0.70.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/board/AgentFailureCard.vue +3 -0
- package/app/components/board/nodes/BlockNode.vue +10 -2
- package/app/components/board/nodes/TaskCard.vue +12 -1
- package/app/components/common/ConfirmDialog.vue +59 -0
- package/app/components/common/EmptyState.vue +35 -0
- package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
- package/app/components/documents/ContextDocumentPicker.vue +8 -4
- package/app/components/fragments/FragmentLibraryManager.vue +10 -0
- package/app/components/github/GitHubPanel.vue +9 -0
- package/app/components/layout/BoardSwitcher.vue +11 -0
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/NotificationsInbox.vue +11 -0
- package/app/components/panels/InspectorPanel.vue +5 -13
- package/app/components/panels/StepContainerStatus.vue +37 -0
- package/app/components/panels/inspector/TaskDependencies.vue +18 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -0
- package/app/components/pipeline/PipelineBuilder.vue +13 -1
- package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
- package/app/components/providers/VendorCredentialsModal.vue +12 -3
- package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
- package/app/components/settings/MergeThresholdsPanel.vue +9 -0
- package/app/components/settings/ModelConfigurationPanel.vue +9 -0
- package/app/components/settings/UserSecretsSection.vue +11 -0
- package/app/components/slack/SlackPanel.vue +9 -0
- package/app/components/tasks/ContextIssuePicker.vue +8 -4
- package/app/composables/useBlockDeletion.ts +63 -0
- package/app/composables/useConfirm.ts +63 -0
- package/app/composables/useKeyboardShortcuts.ts +76 -0
- package/app/pages/index.vue +8 -0
- package/app/stores/agentRuns.spec.ts +56 -0
- package/app/stores/agentRuns.ts +22 -4
- package/app/stores/ui.ts +16 -0
- package/i18n/locales/en.json +103 -5
- package/i18n/locales/es.json +107 -9
- package/i18n/locales/fr.json +107 -9
- package/i18n/locales/he.json +107 -9
- package/i18n/locales/ja.json +107 -9
- package/i18n/locales/pl.json +107 -9
- package/i18n/locales/tr.json +107 -9
- package/i18n/locales/uk.json +107 -9
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ const { t } = useI18n()
|
|
|
13
13
|
const ui = useUiStore()
|
|
14
14
|
const store = useLocalModelsStore()
|
|
15
15
|
const toast = useToast()
|
|
16
|
+
const { confirm } = useConfirm()
|
|
16
17
|
|
|
17
18
|
const open = computed({
|
|
18
19
|
get: () => ui.localModelsOpen,
|
|
@@ -140,6 +141,16 @@ async function save() {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
async function remove(p: LocalRunner) {
|
|
144
|
+
const ok = await confirm({
|
|
145
|
+
title: t('settings.localModelEndpoints.confirmRemove.title'),
|
|
146
|
+
description: t('settings.localModelEndpoints.confirmRemove.body', {
|
|
147
|
+
name: LOCAL_RUNNER_LABELS[p],
|
|
148
|
+
}),
|
|
149
|
+
variant: 'destructive',
|
|
150
|
+
confirmLabel: t('common.remove'),
|
|
151
|
+
icon: 'i-lucide-trash-2',
|
|
152
|
+
})
|
|
153
|
+
if (!ok) return
|
|
143
154
|
busy.value = true
|
|
144
155
|
try {
|
|
145
156
|
await store.remove(p)
|
|
@@ -29,6 +29,7 @@ const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string
|
|
|
29
29
|
|
|
30
30
|
const store = useMergePresetsStore()
|
|
31
31
|
const toast = useToast()
|
|
32
|
+
const { confirm } = useConfirm()
|
|
32
33
|
|
|
33
34
|
// Local editable copy per preset, kept in sync with the store. Percentages are
|
|
34
35
|
// edited 0..100 and stored 0..1.
|
|
@@ -116,6 +117,14 @@ async function makeDefault(p: MergeThresholdPreset) {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
async function remove(p: MergeThresholdPreset) {
|
|
120
|
+
const ok = await confirm({
|
|
121
|
+
title: t('settings.mergeThresholds.confirmDelete.title'),
|
|
122
|
+
description: t('settings.mergeThresholds.confirmDelete.body', { name: p.name }),
|
|
123
|
+
variant: 'destructive',
|
|
124
|
+
confirmLabel: t('common.delete'),
|
|
125
|
+
icon: 'i-lucide-trash-2',
|
|
126
|
+
})
|
|
127
|
+
if (!ok) return
|
|
119
128
|
busy.value = p.id
|
|
120
129
|
try {
|
|
121
130
|
await store.remove(p.id)
|
|
@@ -25,6 +25,7 @@ const agents = useAgentsStore()
|
|
|
25
25
|
const creds = useVendorCredentialsStore()
|
|
26
26
|
const workspace = useWorkspaceStore()
|
|
27
27
|
const toast = useToast()
|
|
28
|
+
const { confirm } = useConfirm()
|
|
28
29
|
|
|
29
30
|
const open = computed({
|
|
30
31
|
get: () => ui.modelConfigOpen,
|
|
@@ -144,6 +145,14 @@ async function setDefault(p: ModelPreset) {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
async function remove(p: ModelPreset) {
|
|
148
|
+
const ok = await confirm({
|
|
149
|
+
title: t('settings.modelConfiguration.confirmDelete.title'),
|
|
150
|
+
description: t('settings.modelConfiguration.confirmDelete.body', { name: p.name }),
|
|
151
|
+
variant: 'destructive',
|
|
152
|
+
confirmLabel: t('common.delete'),
|
|
153
|
+
icon: 'i-lucide-trash-2',
|
|
154
|
+
})
|
|
155
|
+
if (!ok) return
|
|
147
156
|
busy.value = true
|
|
148
157
|
try {
|
|
149
158
|
await presets.remove(p.id)
|
|
@@ -12,6 +12,7 @@ const { t } = useI18n()
|
|
|
12
12
|
const ui = useUiStore()
|
|
13
13
|
const store = useUserSecretsStore()
|
|
14
14
|
const toast = useToast()
|
|
15
|
+
const { confirm } = useConfirm()
|
|
15
16
|
|
|
16
17
|
const open = computed({
|
|
17
18
|
get: () => ui.userSecretsOpen,
|
|
@@ -117,6 +118,16 @@ async function save() {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
async function remove() {
|
|
121
|
+
const ok = await confirm({
|
|
122
|
+
title: t('settings.userSecrets.confirmRemove.title'),
|
|
123
|
+
description: t('settings.userSecrets.confirmRemove.body', {
|
|
124
|
+
name: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
|
|
125
|
+
}),
|
|
126
|
+
variant: 'destructive',
|
|
127
|
+
confirmLabel: t('common.remove'),
|
|
128
|
+
icon: 'i-lucide-trash-2',
|
|
129
|
+
})
|
|
130
|
+
if (!ok) return
|
|
120
131
|
busy.value = true
|
|
121
132
|
try {
|
|
122
133
|
await store.remove(kind.value)
|
|
@@ -13,6 +13,7 @@ const ui = useUiStore()
|
|
|
13
13
|
const slack = useSlackStore()
|
|
14
14
|
const toast = useToast()
|
|
15
15
|
const { t } = useI18n()
|
|
16
|
+
const { confirm } = useConfirm()
|
|
16
17
|
|
|
17
18
|
const open = computed({
|
|
18
19
|
get: () => ui.slackOpen,
|
|
@@ -108,6 +109,14 @@ async function connectWithToken() {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
async function disconnect() {
|
|
112
|
+
const ok = await confirm({
|
|
113
|
+
title: t('slack.confirmDisconnect.title'),
|
|
114
|
+
description: t('slack.confirmDisconnect.body'),
|
|
115
|
+
variant: 'destructive',
|
|
116
|
+
confirmLabel: t('common.disconnect'),
|
|
117
|
+
icon: 'i-lucide-unplug',
|
|
118
|
+
})
|
|
119
|
+
if (!ok) return
|
|
111
120
|
try {
|
|
112
121
|
await slack.disconnect()
|
|
113
122
|
} catch (e) {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
8
|
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
9
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
|
+
import EmptyState from '~/components/common/EmptyState.vue'
|
|
10
11
|
|
|
11
12
|
const props = defineProps<{
|
|
12
13
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -245,15 +246,18 @@ onMounted(() => {
|
|
|
245
246
|
</span>
|
|
246
247
|
</button>
|
|
247
248
|
|
|
248
|
-
<
|
|
249
|
-
|
|
249
|
+
<EmptyState
|
|
250
|
+
v-if="empty"
|
|
251
|
+
compact
|
|
252
|
+
icon="i-lucide-search-x"
|
|
253
|
+
:title="
|
|
250
254
|
query.trim()
|
|
251
255
|
? t('tasks.picker.noMatches')
|
|
252
256
|
: searchable
|
|
253
257
|
? t('tasks.picker.emptySearchable')
|
|
254
258
|
: t('tasks.picker.emptyRefOnly')
|
|
255
|
-
|
|
256
|
-
|
|
259
|
+
"
|
|
260
|
+
/>
|
|
257
261
|
</div>
|
|
258
262
|
</div>
|
|
259
263
|
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Block } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single, confirm-gated block deletion used by BOTH the inspector's Delete button and
|
|
5
|
+
* the global keyboard shortcut, so the two paths can never drift (one safe, one not). It
|
|
6
|
+
* mirrors the ordering the inspector always used — close the inspector, cancel any run, then
|
|
7
|
+
* optimistically delete (the store restores + toasts on backend failure via `RemovalSnapshot`)
|
|
8
|
+
* — and only inserts the confirmation prompt in front of the mutation.
|
|
9
|
+
*
|
|
10
|
+
* A recurring-pipeline task owns its reused block + run history, so removing the schedule
|
|
11
|
+
* deletes them server-side; that case deletes the schedule rather than orphaning it.
|
|
12
|
+
*/
|
|
13
|
+
export function useBlockDeletion() {
|
|
14
|
+
const board = useBoardStore()
|
|
15
|
+
const execution = useExecutionStore()
|
|
16
|
+
const ui = useUiStore()
|
|
17
|
+
const recurring = useRecurringPipelinesStore()
|
|
18
|
+
const { confirm } = useConfirm()
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
|
|
21
|
+
/** Resolve the confirm title/body for a block, matching the inspector's delete-label kinds. */
|
|
22
|
+
function copyFor(block: Block): { title: string; body: string } {
|
|
23
|
+
const schedule = recurring.byBlock(block.id)
|
|
24
|
+
const kind = schedule
|
|
25
|
+
? 'recurring'
|
|
26
|
+
: block.level === 'task'
|
|
27
|
+
? 'task'
|
|
28
|
+
: block.level === 'module'
|
|
29
|
+
? 'module'
|
|
30
|
+
: 'service'
|
|
31
|
+
return {
|
|
32
|
+
title: t(`panels.inspector.confirmDelete.${kind}.title`),
|
|
33
|
+
body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
|
|
38
|
+
if (!block) return false
|
|
39
|
+
const { title, body } = copyFor(block)
|
|
40
|
+
const ok = await confirm({
|
|
41
|
+
title,
|
|
42
|
+
description: body,
|
|
43
|
+
variant: 'destructive',
|
|
44
|
+
confirmLabel: t('common.delete'),
|
|
45
|
+
icon: 'i-lucide-trash-2',
|
|
46
|
+
})
|
|
47
|
+
if (!ok) return false
|
|
48
|
+
|
|
49
|
+
// Close the inspector right away; the stores hide the block optimistically and restore
|
|
50
|
+
// it (with a toast) only if the backend delete fails.
|
|
51
|
+
ui.select(null)
|
|
52
|
+
const schedule = recurring.byBlock(block.id)
|
|
53
|
+
if (schedule) {
|
|
54
|
+
void recurring.remove(schedule.id)
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
execution.cancel(block.id)
|
|
58
|
+
void board.removeBlock(block.id)
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { deleteBlock }
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readonly, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A shared, promise-based confirmation dialog. Call `confirm(...)` from anywhere and
|
|
5
|
+
* `await` the boolean the user chose — resolve on Confirm, reject-to-`false` on Cancel or
|
|
6
|
+
* any dismissal (backdrop click, Escape, route change). One `<ConfirmDialog />` is mounted
|
|
7
|
+
* app-wide (in `pages/index.vue`) and reads this singleton, so callers never render a modal
|
|
8
|
+
* themselves — they just `await useConfirm().confirm({...})` and branch on the result.
|
|
9
|
+
*
|
|
10
|
+
* The state lives at module scope (outside the exported function) so every caller and the
|
|
11
|
+
* single mounted dialog share ONE request queue.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConfirmRequest {
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
confirmLabel?: string
|
|
17
|
+
cancelLabel?: string
|
|
18
|
+
/** `destructive` renders the confirm button in the error colour. */
|
|
19
|
+
variant?: 'default' | 'destructive'
|
|
20
|
+
icon?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const open = ref(false)
|
|
24
|
+
const current = ref<ConfirmRequest | null>(null)
|
|
25
|
+
let resolver: ((ok: boolean) => void) | null = null
|
|
26
|
+
|
|
27
|
+
/** Settle the pending promise (if any) with `ok`, then clear it. */
|
|
28
|
+
function settle(ok: boolean): void {
|
|
29
|
+
const resolve = resolver
|
|
30
|
+
resolver = null
|
|
31
|
+
resolve?.(ok)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useConfirm() {
|
|
35
|
+
function confirm(request: ConfirmRequest): Promise<boolean> {
|
|
36
|
+
// A new request while one is open supersedes the old one — resolve the previous
|
|
37
|
+
// `false` so its awaiter never hangs, then show the new request.
|
|
38
|
+
if (resolver) settle(false)
|
|
39
|
+
current.value = request
|
|
40
|
+
open.value = true
|
|
41
|
+
return new Promise<boolean>((resolve) => {
|
|
42
|
+
resolver = resolve
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function accept(): void {
|
|
47
|
+
open.value = false
|
|
48
|
+
settle(true)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cancel(): void {
|
|
52
|
+
open.value = false
|
|
53
|
+
settle(false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Called by the dialog when `open` flips to false without an explicit accept/cancel
|
|
57
|
+
// (backdrop, Escape, unmount). Any still-pending promise resolves `false`.
|
|
58
|
+
function dismissed(): void {
|
|
59
|
+
if (resolver) settle(false)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { open, current: readonly(current), confirm, accept, cancel, dismissed }
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -18,6 +18,8 @@ import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
|
18
18
|
import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
19
19
|
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
20
20
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
21
|
+
import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
|
|
22
|
+
import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue'
|
|
21
23
|
|
|
22
24
|
// Heavy, rarely-open panels — code-split into their own chunks via defineAsyncComponent
|
|
23
25
|
// and mounted only while their ui open-flag is set (the v-if gates in the template), so
|
|
@@ -113,6 +115,10 @@ const models = useModelsStore()
|
|
|
113
115
|
const ui = useUiStore()
|
|
114
116
|
const aiReadiness = useAiReadiness()
|
|
115
117
|
|
|
118
|
+
// App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
|
|
119
|
+
// for the cheatsheet). Registered ONCE here so a single global listener owns them.
|
|
120
|
+
useKeyboardShortcuts()
|
|
121
|
+
|
|
116
122
|
// Load the board from the backend before rendering it.
|
|
117
123
|
onMounted(() => {
|
|
118
124
|
void workspace.init()
|
|
@@ -307,6 +313,8 @@ watch(
|
|
|
307
313
|
<AddTaskModal />
|
|
308
314
|
<CommandBar />
|
|
309
315
|
<PersonalCredentialModal />
|
|
316
|
+
<ConfirmDialog />
|
|
317
|
+
<KeyboardShortcutsHelp />
|
|
310
318
|
|
|
311
319
|
<!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
|
|
312
320
|
first open (its own chunk) rather than bloating the initial bundle. -->
|
|
@@ -0,0 +1,56 @@
|
|
|
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 })])
|
|
39
|
+
expect(store.bootstrapJobs[0]!.status).toBe('failed')
|
|
40
|
+
expect(store.byBlock.blk_j1!.status).toBe('failed')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('hydrate DOES apply a genuinely newer snapshot', () => {
|
|
44
|
+
store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 2 }))
|
|
45
|
+
store.hydrate([job('j1', { status: 'succeeded', updatedAt: 9 })])
|
|
46
|
+
expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('upsertBootstrap ignores an older/out-of-order event but applies newer/equal', () => {
|
|
50
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
51
|
+
store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 3 })) // stale → ignored
|
|
52
|
+
expect(store.bootstrapJobs[0]!.status).toBe('failed')
|
|
53
|
+
store.upsertBootstrap(job('j1', { status: 'succeeded', updatedAt: 5 })) // equal → applied
|
|
54
|
+
expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
|
|
55
|
+
})
|
|
56
|
+
})
|
package/app/stores/agentRuns.ts
CHANGED
|
@@ -59,9 +59,24 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
59
59
|
*/
|
|
60
60
|
const envConfigRepairJobs = ref<EnvConfigRepairJob[]>([])
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Reconcile the cached bootstrap runs with a server snapshot. A snapshot is
|
|
64
|
+
* authoritative EXCEPT where a live event has already advanced a run past what
|
|
65
|
+
* this (possibly stale) read observed: a `board` event triggers a debounced
|
|
66
|
+
* `workspace.refresh()`, and that snapshot read can resolve AFTER a newer
|
|
67
|
+
* `bootstrap` event already landed. A blind replace would then REGRESS a terminal
|
|
68
|
+
* run — e.g. a `failed` bootstrap reverting to `running`, leaving the frame stuck
|
|
69
|
+
* on the "bootstrapping…" badge with no further event to correct it (a failed run
|
|
70
|
+
* emits nothing more). Keep the newer-by-`updatedAt` version per id so a lagging
|
|
71
|
+
* refresh can't clobber a live transition.
|
|
72
|
+
*/
|
|
63
73
|
function hydrate(jobs: BootstrapJob[]) {
|
|
64
|
-
|
|
74
|
+
const held = new Map(bootstrapJobs.value.map((j) => [j.id, j]))
|
|
75
|
+
const reconciled = jobs.map((incoming) => {
|
|
76
|
+
const current = held.get(incoming.id)
|
|
77
|
+
return current && current.updatedAt > incoming.updatedAt ? current : incoming
|
|
78
|
+
})
|
|
79
|
+
bootstrapJobs.value = reconciled.sort((a, b) => b.createdAt - a.createdAt)
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/** Replace the cached env-config-repair runs with a server snapshot. */
|
|
@@ -92,8 +107,11 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
92
107
|
*/
|
|
93
108
|
function upsertBootstrap(job: BootstrapJob) {
|
|
94
109
|
const i = bootstrapJobs.value.findIndex((j) => j.id === job.id)
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
// Monotonic by `updatedAt`: never let a stale/out-of-order event regress a run a
|
|
111
|
+
// newer one already advanced (same guard as {@link hydrate}).
|
|
112
|
+
if (i >= 0) {
|
|
113
|
+
if (job.updatedAt >= bootstrapJobs.value[i]!.updatedAt) bootstrapJobs.value[i] = job
|
|
114
|
+
} else bootstrapJobs.value.unshift(job)
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
/**
|
package/app/stores/ui.ts
CHANGED
|
@@ -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.
|
|
@@ -446,6 +449,15 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
446
449
|
function toggleCommandBar() {
|
|
447
450
|
commandBarOpen.value = !commandBarOpen.value
|
|
448
451
|
}
|
|
452
|
+
function openShortcutsHelp() {
|
|
453
|
+
shortcutsHelpOpen.value = true
|
|
454
|
+
}
|
|
455
|
+
function closeShortcutsHelp() {
|
|
456
|
+
shortcutsHelpOpen.value = false
|
|
457
|
+
}
|
|
458
|
+
function toggleShortcutsHelp() {
|
|
459
|
+
shortcutsHelpOpen.value = !shortcutsHelpOpen.value
|
|
460
|
+
}
|
|
449
461
|
function openMobileNav() {
|
|
450
462
|
mobileNavOpen.value = true
|
|
451
463
|
}
|
|
@@ -754,6 +766,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
754
766
|
slackOpen,
|
|
755
767
|
fragmentLibraryOpen,
|
|
756
768
|
commandBarOpen,
|
|
769
|
+
shortcutsHelpOpen,
|
|
757
770
|
mobileNavOpen,
|
|
758
771
|
integrationsOpen,
|
|
759
772
|
cameFromIntegrations,
|
|
@@ -829,6 +842,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
829
842
|
openCommandBar,
|
|
830
843
|
closeCommandBar,
|
|
831
844
|
toggleCommandBar,
|
|
845
|
+
openShortcutsHelp,
|
|
846
|
+
closeShortcutsHelp,
|
|
847
|
+
toggleShortcutsHelp,
|
|
832
848
|
openMobileNav,
|
|
833
849
|
closeMobileNav,
|
|
834
850
|
toggleMobileNav,
|