@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.
Files changed (41) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -0
  2. package/app/components/board/nodes/BlockNode.vue +10 -2
  3. package/app/components/board/nodes/TaskCard.vue +12 -1
  4. package/app/components/common/ConfirmDialog.vue +59 -0
  5. package/app/components/common/EmptyState.vue +35 -0
  6. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  7. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  8. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  9. package/app/components/github/GitHubPanel.vue +9 -0
  10. package/app/components/layout/BoardSwitcher.vue +11 -0
  11. package/app/components/layout/CommandBar.vue +8 -0
  12. package/app/components/layout/NotificationsInbox.vue +11 -0
  13. package/app/components/panels/InspectorPanel.vue +5 -13
  14. package/app/components/panels/StepContainerStatus.vue +37 -0
  15. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  16. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  17. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  18. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  19. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  20. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  21. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  22. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  23. package/app/components/settings/UserSecretsSection.vue +11 -0
  24. package/app/components/slack/SlackPanel.vue +9 -0
  25. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  26. package/app/composables/useBlockDeletion.ts +63 -0
  27. package/app/composables/useConfirm.ts +63 -0
  28. package/app/composables/useKeyboardShortcuts.ts +76 -0
  29. package/app/pages/index.vue +8 -0
  30. package/app/stores/agentRuns.spec.ts +56 -0
  31. package/app/stores/agentRuns.ts +22 -4
  32. package/app/stores/ui.ts +16 -0
  33. package/i18n/locales/en.json +103 -5
  34. package/i18n/locales/es.json +107 -9
  35. package/i18n/locales/fr.json +107 -9
  36. package/i18n/locales/he.json +107 -9
  37. package/i18n/locales/ja.json +107 -9
  38. package/i18n/locales/pl.json +107 -9
  39. package/i18n/locales/tr.json +107 -9
  40. package/i18n/locales/uk.json +107 -9
  41. 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
- <p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
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
- </p>
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
+ }
@@ -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
+ })
@@ -59,9 +59,24 @@ 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. */
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
- bootstrapJobs.value = [...jobs].sort((a, b) => b.createdAt - a.createdAt)
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
- if (i >= 0) bootstrapJobs.value[i] = job
96
- else bootstrapJobs.value.unshift(job)
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,