@cat-factory/app 0.311.2 → 0.312.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 (210) hide show
  1. package/README.md +92 -4
  2. package/app/app.config.ts +7 -0
  3. package/app/components/assistant/AssistantModal.vue +2 -5
  4. package/app/components/auth/AuthGate.vue +2 -1
  5. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +14 -15
  6. package/app/components/binaryOutput/StoredAssetView.vue +1 -3
  7. package/app/components/board/AddTaskModal.vue +3 -10
  8. package/app/components/board/AgentFailureCard.vue +4 -5
  9. package/app/components/board/AgentStopButton.vue +5 -7
  10. package/app/components/board/BoardCanvas.vue +31 -30
  11. package/app/components/board/CreateInitiativeModal.vue +2 -4
  12. package/app/components/board/RecurringPipelineModal.vue +2 -7
  13. package/app/components/board/nodes/AgentChip.vue +3 -2
  14. package/app/components/board/nodes/BlockNode.vue +14 -11
  15. package/app/components/board/nodes/InitiativeCard.vue +6 -3
  16. package/app/components/board/nodes/TaskCard.vue +45 -212
  17. package/app/components/bootstrap/AdoptionReviewModal.vue +2 -5
  18. package/app/components/bootstrap/BootstrapModal.vue +6 -15
  19. package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
  20. package/app/components/brainstorm/BrainstormWindow.vue +32 -26
  21. package/app/components/bugFishing/BugFishingWindow.vue +15 -13
  22. package/app/components/clarity/ClarityReviewWindow.vue +31 -26
  23. package/app/components/common/ClarificationItem.vue +3 -1
  24. package/app/components/common/EmptyState.vue +41 -19
  25. package/app/components/common/InterviewGateNotice.vue +3 -1
  26. package/app/components/common/Spinner.vue +28 -0
  27. package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
  28. package/app/components/docs/DocInterviewWindow.vue +6 -6
  29. package/app/components/documents/ContextDocumentPicker.vue +2 -2
  30. package/app/components/documents/DocumentImportModal.vue +9 -8
  31. package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
  32. package/app/components/documents/DocumentTemplatesModal.vue +13 -7
  33. package/app/components/documents/RepoContextDocPicker.vue +2 -2
  34. package/app/components/documents/SpawnPreviewModal.vue +4 -6
  35. package/app/components/documents/StartFromDesignModal.vue +2 -1
  36. package/app/components/documents/TaskContextDocs.vue +4 -5
  37. package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
  38. package/app/components/environments/steps/EnvPickStep.vue +7 -3
  39. package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
  40. package/app/components/focus/BlockFocusView.vue +6 -5
  41. package/app/components/followUp/FollowUpWindow.vue +7 -9
  42. package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
  43. package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
  44. package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
  45. package/app/components/foundational/FoundationalServiceSources.vue +20 -22
  46. package/app/components/foundational/FoundationalSuppressions.vue +2 -2
  47. package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
  48. package/app/components/fragments/FragmentLibraryManager.vue +36 -34
  49. package/app/components/fragments/FragmentSelector.vue +7 -3
  50. package/app/components/gates/GateResultView.vue +6 -9
  51. package/app/components/github/AddServiceFromRepoModal.vue +6 -10
  52. package/app/components/github/GitHubConnect.vue +10 -17
  53. package/app/components/github/GitHubPanel.vue +44 -47
  54. package/app/components/github/RepoTreeBrowser.vue +9 -5
  55. package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
  56. package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
  57. package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
  58. package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
  59. package/app/components/humanTest/HumanTestWindow.vue +2 -1
  60. package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
  61. package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
  62. package/app/components/judge/JudgeResultView.vue +2 -7
  63. package/app/components/kaizen/KaizenPanel.vue +20 -5
  64. package/app/components/kaizen/KaizenStepStatus.vue +2 -1
  65. package/app/components/layout/AccountAuditLog.vue +11 -3
  66. package/app/components/layout/AccountDeploymentSettings.vue +17 -62
  67. package/app/components/layout/AccountFailureKindRules.vue +6 -5
  68. package/app/components/layout/AccountModelPolicySettings.vue +2 -6
  69. package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
  70. package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
  71. package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
  72. package/app/components/layout/AccountTeamSettings.vue +12 -9
  73. package/app/components/layout/BoardSwitcher.vue +2 -2
  74. package/app/components/layout/BoardToolbar.vue +27 -37
  75. package/app/components/layout/BoardTopOverlays.vue +8 -2
  76. package/app/components/layout/CommandBar.vue +7 -3
  77. package/app/components/layout/ConnectionStatusBanner.vue +2 -1
  78. package/app/components/layout/IntegrationsHub.vue +7 -3
  79. package/app/components/layout/ModelProvidersHub.vue +7 -3
  80. package/app/components/layout/NotificationsInbox.vue +3 -11
  81. package/app/components/layout/SideBar.vue +5 -0
  82. package/app/components/layout/WorkViewSwitcher.vue +64 -0
  83. package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
  84. package/app/components/media/ArtifactLightbox.vue +4 -3
  85. package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
  86. package/app/components/observability/ToolCallList.vue +12 -17
  87. package/app/components/panels/AgentStepDetail.vue +5 -5
  88. package/app/components/panels/GenericStructuredResultView.vue +6 -9
  89. package/app/components/panels/InspectorPanel.vue +2 -1
  90. package/app/components/panels/MergerResultView.vue +5 -6
  91. package/app/components/panels/ObservabilityPanel.vue +26 -41
  92. package/app/components/panels/OperatorDashboardPanel.vue +29 -17
  93. package/app/components/panels/ReportsPanel.vue +17 -11
  94. package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
  95. package/app/components/panels/RunDetailLoadState.vue +4 -3
  96. package/app/components/panels/StepContainerStatus.vue +4 -3
  97. package/app/components/panels/StepDelegatedStatus.vue +3 -2
  98. package/app/components/panels/StepMetadataCard.vue +6 -8
  99. package/app/components/panels/StepRunMeta.vue +2 -5
  100. package/app/components/panels/inspector/ContainerSummary.vue +7 -1
  101. package/app/components/panels/inspector/EpicChildren.vue +7 -3
  102. package/app/components/panels/inspector/FrontendConfig.vue +7 -3
  103. package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
  104. package/app/components/panels/inspector/ServiceConnections.vue +7 -3
  105. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
  106. package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
  107. package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
  108. package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
  109. package/app/components/panels/inspector/TaskExecution.vue +5 -6
  110. package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
  111. package/app/components/panels/inspector/TaskStructure.vue +0 -1
  112. package/app/components/pipeline/AgentPromptEditor.vue +25 -18
  113. package/app/components/pipeline/PipelineBuilder.vue +14 -24
  114. package/app/components/pipeline/PipelineProgress.vue +16 -17
  115. package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
  116. package/app/components/prReview/PrReviewWindow.vue +16 -20
  117. package/app/components/providers/ApiKeysSection.vue +2 -6
  118. package/app/components/providers/PersonalCredentialModal.vue +2 -6
  119. package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
  120. package/app/components/providers/VendorCredentialsModal.vue +2 -6
  121. package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
  122. package/app/components/queue/QueueCard.vue +269 -0
  123. package/app/components/queue/QueueView.vue +192 -0
  124. package/app/components/ralph/RalphLoopResultView.vue +2 -7
  125. package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
  126. package/app/components/sandbox/SandboxPanel.vue +37 -25
  127. package/app/components/settings/AccountSettingsPanel.vue +6 -3
  128. package/app/components/settings/ApiTokensPanel.vue +2 -6
  129. package/app/components/settings/BudgetSettings.vue +2 -2
  130. package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
  131. package/app/components/settings/CloudflareHandlerSection.vue +3 -7
  132. package/app/components/settings/ConsensusGroupsSection.vue +7 -3
  133. package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
  134. package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
  135. package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
  136. package/app/components/settings/IssueTrackerPanel.vue +9 -9
  137. package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
  138. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
  139. package/app/components/settings/McpAuthorizeScreen.vue +8 -3
  140. package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
  141. package/app/components/settings/ModelConfigurationPanel.vue +45 -17
  142. package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
  143. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
  144. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  145. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  146. package/app/components/settings/RiskPolicyPanel.vue +4 -16
  147. package/app/components/settings/SharedStacksPanel.vue +8 -18
  148. package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
  149. package/app/components/settings/UsageSettings.vue +11 -2
  150. package/app/components/settings/UserSecretsSection.vue +5 -7
  151. package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
  152. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
  153. package/app/components/skills/ReviewSkillQueue.vue +7 -3
  154. package/app/components/skills/SkillLibraryManager.vue +20 -18
  155. package/app/components/slack/SlackPanel.vue +5 -8
  156. package/app/components/spec/ServiceSpecWindow.vue +32 -29
  157. package/app/components/tasks/BugHuntModal.vue +23 -19
  158. package/app/components/tasks/ContextIssuePicker.vue +2 -2
  159. package/app/components/tasks/TaskContextIssues.vue +4 -5
  160. package/app/components/tasks/TaskImportModal.vue +10 -8
  161. package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
  162. package/app/components/testing/TestReportWindow.vue +6 -12
  163. package/app/components/tutorial/TutorialCatalogue.vue +7 -3
  164. package/app/components/tutorial/TutorialOverlay.vue +2 -1
  165. package/app/components/tutorial/TutorialPrompt.vue +7 -3
  166. package/app/components/vcs/GitLabConnect.vue +2 -6
  167. package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
  168. package/app/composables/toastTitleKeys.spec.ts +143 -0
  169. package/app/composables/useActionToast.spec.ts +71 -0
  170. package/app/composables/useActionToast.ts +101 -0
  171. package/app/composables/useBlockDeletion.ts +3 -6
  172. package/app/composables/useConfirmAction.ts +5 -3
  173. package/app/composables/useContextLinking.spec.ts +3 -0
  174. package/app/composables/useContextLinking.ts +11 -16
  175. package/app/composables/useCopyToClipboard.ts +4 -3
  176. package/app/composables/useFrameLanes.ts +6 -82
  177. package/app/composables/useNavContributions.ts +6 -5
  178. package/app/composables/usePipelineErrorToast.spec.ts +24 -0
  179. package/app/composables/usePipelineErrorToast.ts +26 -2
  180. package/app/composables/usePipelineLibraryActions.ts +8 -9
  181. package/app/composables/useTaskActions.ts +254 -0
  182. package/app/composables/useTaskLaneClassifier.ts +88 -0
  183. package/app/modular/nav-contributions.spec.ts +4 -0
  184. package/app/modular/nav-contributions.ts +31 -0
  185. package/app/modular/tutorial-tours.ts +10 -0
  186. package/app/pages/index.vue +42 -8
  187. package/app/stores/board/context.ts +4 -3
  188. package/app/stores/board/placement.ts +8 -20
  189. package/app/stores/board/removal.ts +4 -13
  190. package/app/stores/board.spec.ts +10 -1
  191. package/app/stores/board.ts +3 -2
  192. package/app/stores/workView.ts +51 -0
  193. package/app/stores/workspaceQueue.ts +148 -0
  194. package/app/utils/bootstrapSteps.ts +11 -2
  195. package/app/utils/pipelineRender.ts +21 -8
  196. package/app/utils/queueSections.spec.ts +111 -0
  197. package/app/utils/queueSections.ts +117 -0
  198. package/app/utils/workView.spec.ts +16 -0
  199. package/app/utils/workView.ts +25 -0
  200. package/i18n/locales/de.json +73 -32
  201. package/i18n/locales/en.json +73 -32
  202. package/i18n/locales/es.json +73 -32
  203. package/i18n/locales/fr.json +73 -32
  204. package/i18n/locales/he.json +73 -32
  205. package/i18n/locales/it.json +73 -32
  206. package/i18n/locales/ja.json +73 -32
  207. package/i18n/locales/pl.json +73 -32
  208. package/i18n/locales/tr.json +73 -32
  209. package/i18n/locales/uk.json +73 -32
  210. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { Ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
+ import type { ActionToast } from '~/composables/useActionToast'
3
4
 
4
5
  /** A detached subtree captured before an optimistic delete, restored on failure. */
5
6
  export interface RemovalSnapshot {
@@ -31,7 +32,7 @@ export const UNDO_WINDOW_MS = 6000
31
32
  * the `board` store setup and threaded into {@link createBoardMutations} / {@link createBoardRemoval}
32
33
  * so the split operations stay behaviourally identical to the original single-closure store — the
33
34
  * factories are cohesive extractions purely to keep each function within the size budget, not new
34
- * seams. `api`/`toast`/`tr`/`present` are the store's own resolved handles (a store runs outside a
35
+ * seams. `api`/`actionToast`/`tr`/`present` are the store's own resolved handles (a store runs outside a
35
36
  * component `setup`, so `tr` bridges to the Nuxt app's global i18n instance).
36
37
  */
37
38
  export interface BoardWriteContext {
@@ -41,10 +42,10 @@ export interface BoardWriteContext {
41
42
  pendingRemovals: Map<string, PendingRemoval>
42
43
  pendingDoomed: Set<string>
43
44
  api: ReturnType<typeof useApi>
44
- toast: ReturnType<typeof useToast>
45
+ actionToast: ActionToast
45
46
  tr: (key: string, params?: Record<string, unknown>) => string
46
47
  /**
47
- * The SPA's one failure-toast funnel (`usePipelineErrorToast().present`), threaded like `toast`
48
+ * The SPA's one failure-toast funnel (`usePipelineErrorToast().present`), threaded like `actionToast`
48
49
  * because it is resolved in the store setup. Every rolled-back write reports through it rather
49
50
  * than building its own `{ title, description: err.message }`, so a board failure reads the same
50
51
  * as every other one: translated copy, and the raw detail copyable behind the disclosure.
@@ -35,7 +35,7 @@ function blockAsPatchable(block: Block): PatchSnapshot {
35
35
  * in-closure functions, and the split is purely to keep every function within the size budget.
36
36
  */
37
37
  export function createBoardPlacement(ctx: BoardWriteContext) {
38
- const { blocks, getBlock, upsert, api, toast, tr, present } = ctx
38
+ const { blocks, getBlock, upsert, api, actionToast, tr, present } = ctx
39
39
 
40
40
  /**
41
41
  * Move a block into a new container at a new local position. Drag-reparent commits
@@ -78,19 +78,12 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
78
78
  // Offer an undo back to the previous container (a drag overshoot is easy). The undo
79
79
  // move is itself non-undoable so the toast doesn't ping-pong.
80
80
  if (opts.undoable && prevParentId) {
81
- toast.add({
82
- title: tr('board.toast.moved', { name }),
83
- icon: 'i-lucide-move',
84
- color: 'neutral',
85
- duration: UNDO_WINDOW_MS,
86
- actions: [
87
- {
88
- label: tr('common.undo'),
89
- icon: 'i-lucide-undo-2',
90
- onClick: () =>
91
- void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
92
- },
93
- ],
81
+ actionToast.success('board.toast.moved', {
82
+ params: { name },
83
+ undo: {
84
+ run: () => void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
85
+ windowMs: UNDO_WINDOW_MS,
86
+ },
94
87
  })
95
88
  }
96
89
  } catch (e) {
@@ -102,12 +95,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
102
95
  // translated prose, so map it here; anything else keeps the raw message as the last resort.
103
96
  const refusal = moveRefusalKey(e)
104
97
  if (refusal) {
105
- toast.add({
106
- title: tr('board.toast.moveFailed'),
107
- description: tr(refusal),
108
- icon: 'i-lucide-triangle-alert',
109
- color: 'error',
110
- })
98
+ actionToast.error('board.toast.moveFailed', { description: tr(refusal) })
111
99
  } else present(e, 'board.toast.moveFailed')
112
100
  }
113
101
  }
@@ -11,7 +11,7 @@ import { UNDO_WINDOW_MS } from './context'
11
11
  * stays internal — it is only wired into the delete toast's undo action.
12
12
  */
13
13
  export function createBoardRemoval(ctx: BoardWriteContext) {
14
- const { blocks, getBlock, pendingRemovals, pendingDoomed, api, toast, tr, present } = ctx
14
+ const { blocks, getBlock, pendingRemovals, pendingDoomed, api, actionToast, present } = ctx
15
15
 
16
16
  /**
17
17
  * Optimistically drop a block and its descendants from the cache, returning a
@@ -148,18 +148,9 @@ export function createBoardRemoval(ctx: BoardWriteContext) {
148
148
  const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
149
149
  pendingRemovals.set(id, { snap, timer, wsId })
150
150
 
151
- toast.add({
152
- title: tr('board.toast.deleted', { name: block.title }),
153
- icon: 'i-lucide-trash-2',
154
- color: 'neutral',
155
- duration: UNDO_WINDOW_MS,
156
- actions: [
157
- {
158
- label: tr('common.undo'),
159
- icon: 'i-lucide-undo-2',
160
- onClick: () => undoRemove(id),
161
- },
162
- ],
151
+ actionToast.success('board.toast.deleted', {
152
+ params: { name: block.title },
153
+ undo: { run: () => undoRemove(id), windowMs: UNDO_WINDOW_MS },
163
154
  })
164
155
  }
165
156
 
@@ -3,6 +3,7 @@ import { setActivePinia, createPinia } from 'pinia'
3
3
  import type { Block, BlockStatus } from '~/types/domain'
4
4
  import { useBoardStore } from '~/stores/board'
5
5
  import { useWorkspaceStore } from '~/stores/workspace'
6
+ import { UNDO_WINDOW_MS } from '~/stores/board/context'
6
7
  import { EMPTY_FRAME_SIZE } from '~/utils/framePlacement'
7
8
  import { frameContentSize, laneBodyHeightIn, LANE_GEOMETRY } from '~/utils/laneGeometry'
8
9
 
@@ -591,9 +592,17 @@ describe('board store deferred delete + undo', () => {
591
592
  })
592
593
 
593
594
  it('undo cancels the pending delete and restores the subtree', async () => {
594
- const { store, removeSpy, actions } = setup(async () => {})
595
+ const { store, removeSpy, addSpy, actions } = setup(async () => {})
595
596
  store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
596
597
  store.removeBlock('f1')
598
+ // The toast stays exactly as long as the delete is held open, or its Undo would undo nothing.
599
+ expect(addSpy).toHaveBeenCalledWith(
600
+ expect.objectContaining({
601
+ title: 'board.toast.deleted',
602
+ color: 'success',
603
+ duration: UNDO_WINDOW_MS,
604
+ }),
605
+ )
597
606
  expect(actions).toHaveLength(1)
598
607
  actions[0]!.onClick()
599
608
  expect(store.getBlock('f1')?.id).toBe('f1')
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import { useBlockQueries } from '~/composables/useBlockQueries'
5
+ import { useActionToast } from '~/composables/useActionToast'
5
6
  import type { PendingRemoval } from '~/stores/board/context'
6
7
  import { createBoardMutations } from '~/stores/board/mutations'
7
8
  import { createBoardPlacement } from '~/stores/board/placement'
@@ -19,7 +20,7 @@ import { createBoardRemoval } from '~/stores/board/removal'
19
20
  */
20
21
  export const useBoardStore = defineStore('board', () => {
21
22
  const api = useApi()
22
- const toast = useToast()
23
+ const actionToast = useActionToast()
23
24
  // Stores run outside a component `setup`, so resolve translations through the Nuxt app's
24
25
  // global i18n instance (the same handle `plugins/locale.client.ts` uses) rather than
25
26
  // `useI18n()`, which requires an active component instance.
@@ -148,7 +149,7 @@ export const useBoardStore = defineStore('board', () => {
148
149
  pendingRemovals,
149
150
  pendingDoomed,
150
151
  api,
151
- toast,
152
+ actionToast,
152
153
  tr,
153
154
  present: usePipelineErrorToast().present,
154
155
  }
@@ -0,0 +1,51 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import { DEFAULT_WORK_VIEW, isWorkView, WORK_VIEW_PARAM, type WorkView } from '~/utils/workView'
4
+
5
+ /**
6
+ * Which view of the board this person is looking at: the queue or the canvas.
7
+ *
8
+ * Persisted per BROWSER in a cookie, like the interface tier and the lane order: it is the view
9
+ * used LAST in this browser, so a person who prefers the canvas stays on it, and nobody on another
10
+ * browser is moved by their choice. See `utils/workView.ts` for why the queue is the default.
11
+ */
12
+ export const useWorkViewStore = defineStore(
13
+ 'workView',
14
+ () => {
15
+ const tutorial = useTutorialStore()
16
+ // Stored raw and narrowed on read, as `uiMode` does: the persisted value is untrusted input.
17
+ const storedView = ref<string>(DEFAULT_WORK_VIEW)
18
+ /**
19
+ * The view on screen. A running tour holds the CANVAS for its whole length: every tour anchors
20
+ * to canvas nodes and the canvas toolbar, which the queue covers or unmounts, so a switch mid-
21
+ * tour would leave its later steps with nothing to point at. A choice made meanwhile is kept
22
+ * and applies when the tour ends.
23
+ */
24
+ const view = computed<WorkView>(() => {
25
+ if (tutorial.touring) return 'board'
26
+ return isWorkView(storedView.value) ? storedView.value : DEFAULT_WORK_VIEW
27
+ })
28
+
29
+ function show(next: WorkView) {
30
+ storedView.value = next
31
+ }
32
+
33
+ /**
34
+ * Honour a `?view=queue` / `?view=board` link, then strip the parameter so a reload or a
35
+ * copied URL does not re-apply it over a later choice. Same shape as the run deep link. A
36
+ * `view` value that names no view is dropped without effect, rather than guessed onto one.
37
+ */
38
+ function consumeDeepLink() {
39
+ if (!import.meta.client) return
40
+ const url = new URL(window.location.href)
41
+ if (!url.searchParams.has(WORK_VIEW_PARAM)) return
42
+ const asked = url.searchParams.get(WORK_VIEW_PARAM)
43
+ url.searchParams.delete(WORK_VIEW_PARAM)
44
+ window.history.replaceState(window.history.state, '', url.toString())
45
+ if (isWorkView(asked)) show(asked)
46
+ }
47
+
48
+ return { storedView, view, show, consumeDeepLink }
49
+ },
50
+ { persist: { pick: ['storedView'] } },
51
+ )
@@ -0,0 +1,148 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { useTaskLaneClassifier, type ClassifiedEntry } from '~/composables/useTaskLaneClassifier'
5
+ import {
6
+ assembleQueue,
7
+ QUEUE_SECTION_BY_REASON,
8
+ type QueueAssembly,
9
+ type QueueEntry,
10
+ } from '~/utils/queueSections'
11
+
12
+ /**
13
+ * Every task on the board, across every service, classified ONCE and filed into queue sections.
14
+ *
15
+ * The store-facing half of `utils/queueSections.ts`, and a STORE rather than a composable for the
16
+ * reason the swimlane README gives: a workspace-wide derivation belongs on one owner, never on each
17
+ * consumer. The queue view, the sidebar's attention counter AND every frame's swimlanes
18
+ * (`useFrameLanes`) read the one classification here, so a board pays for each task's verdict once
19
+ * per change, however many surfaces show it.
20
+ *
21
+ * It walks the service frames rather than `board.allTasks` so it holds exactly the tasks the
22
+ * frames' own swimlanes hold (direct tasks plus those inside modules): a task the canvas shows
23
+ * nowhere is not one the queue may claim needs you.
24
+ */
25
+ export const useWorkspaceQueueStore = defineStore('workspaceQueue', () => {
26
+ const board = useBoardStore()
27
+ const execution = useExecutionStore()
28
+ const workspace = useWorkspaceStore()
29
+ const settings = useWorkspaceSettingsStore()
30
+ const reviews = useReviewStage()
31
+ const { classify } = useTaskLaneClassifier()
32
+
33
+ /**
34
+ * Each task's lane verdict, keyed by id. `order` is the task's position in its OWN frame, which
35
+ * is the tiebreak the frame's lanes sort by.
36
+ */
37
+ const classified = computed(() => {
38
+ const byId = new Map<string, ClassifiedEntry>()
39
+ for (const frame of board.frames) {
40
+ board
41
+ .allTasksUnder(frame.id)
42
+ .forEach((task, order) => byId.set(task.id, classify(task, order)))
43
+ }
44
+ return byId
45
+ })
46
+
47
+ /** Narrow the queue to one service; null means all of them. Session-only, like a search box. */
48
+ const serviceFilter = ref<string | null>(null)
49
+ // A filter belongs to the board it was set on: another board's service id matches nothing there.
50
+ watch(
51
+ () => workspace.workspaceId,
52
+ () => (serviceFilter.value = null),
53
+ )
54
+
55
+ /** The service frames, for the filter. Board order, which is the order the canvas lays out. */
56
+ const services = computed(() => board.frames.map((f) => ({ id: f.id, title: f.title })))
57
+
58
+ /** The filter in force: a service deleted since it was picked narrows to nothing, so it lapses. */
59
+ const activeFilter = computed(() =>
60
+ serviceFilter.value && services.value.some((s) => s.id === serviceFilter.value)
61
+ ? serviceFilter.value
62
+ : null,
63
+ )
64
+
65
+ /** Show every service. The surfaces that send a person to the queue call it, so a filter set
66
+ * earlier cannot hide the work they were sent to see. */
67
+ function clearFilter() {
68
+ serviceFilter.value = null
69
+ }
70
+
71
+ /**
72
+ * A decision or approval on a block that is not a task in a service: an initiative's planning
73
+ * run, or a frame-level run. The swimlanes have no lane for it, but it waits on a person all the
74
+ * same, and the toolbar's decision count includes it, so the queue lists it under Needs you.
75
+ */
76
+ const parkedElsewhere = computed<QueueEntry[]>(() => {
77
+ const ids = new Set<string>()
78
+ for (const d of execution.openDecisions) ids.add(d.blockId)
79
+ for (const a of execution.openApprovals) {
80
+ if (!reviews.isBackground(a.agentKind, a.blockId)) ids.add(a.blockId)
81
+ }
82
+ const out: QueueEntry[] = []
83
+ for (const id of ids) {
84
+ if (classified.value.has(id)) continue
85
+ const block = board.getBlock(id)
86
+ if (!block) continue
87
+ out.push(parkEntry(block, out.length))
88
+ }
89
+ return out
90
+ })
91
+
92
+ function parkEntry(block: Block, order: number): QueueEntry {
93
+ const { entry } = classify(block, order)
94
+ const service = board.serviceOf(block)
95
+ return {
96
+ ...entry,
97
+ reason: execution.decisionsByBlock.has(block.id) ? 'decision' : 'approval',
98
+ service: service ? { id: service.id, title: service.title } : null,
99
+ }
100
+ }
101
+
102
+ /** Everything the queue lists, before the filter. The attention counter reads this, unfiltered. */
103
+ const entries = computed<QueueEntry[]>(() => {
104
+ const out: QueueEntry[] = []
105
+ for (const frame of board.frames) {
106
+ const service = { id: frame.id, title: frame.title }
107
+ // `classified` walks these same frames and tasks, so every one of them has a verdict.
108
+ for (const task of board.allTasksUnder(frame.id)) {
109
+ out.push({ ...classified.value.get(task.id)!.entry, service })
110
+ }
111
+ }
112
+ return out.concat(parkedElsewhere.value)
113
+ })
114
+
115
+ // `Date.now()` is read non-reactively, as the frame's Done lane does: the window is
116
+ // re-evaluated on every board or run change, which on a live board is constantly.
117
+ const queue = computed<QueueAssembly>(() =>
118
+ assembleQueue(
119
+ activeFilter.value
120
+ ? entries.value.filter((e) => e.service?.id === activeFilter.value)
121
+ : entries.value,
122
+ Date.now(),
123
+ {
124
+ maxItems: settings.settings.doneLaneMaxItems,
125
+ retentionDays: settings.settings.doneLaneRetentionDays,
126
+ },
127
+ ),
128
+ )
129
+
130
+ /**
131
+ * The ONE attention count the queue view shows: what a human must act on before an agent can
132
+ * continue, across every service and regardless of the filter. A pull request waiting to merge
133
+ * is not in it, because nothing is stalled behind it (see `utils/queueSections.ts`).
134
+ */
135
+ const needsYouCount = computed(
136
+ () => entries.value.filter((e) => QUEUE_SECTION_BY_REASON[e.reason] === 'needs_you').length,
137
+ )
138
+
139
+ return {
140
+ classified,
141
+ serviceFilter,
142
+ activeFilter,
143
+ clearFilter,
144
+ services,
145
+ queue,
146
+ needsYouCount,
147
+ }
148
+ })
@@ -9,9 +9,11 @@ import type { BootstrapStepState } from '@cat-factory/contracts'
9
9
  // every instance: the step list renders on every in-progress, parked and failed bootstrap card on
10
10
  // the board, plus the inspector and the failure card.
11
11
 
12
- /** How one step state renders: its icon, the icon's tone, and the label's. */
12
+ /** How one step state renders: its icon, whether it spins (through `<Spinner :spinning>`), the
13
+ * icon's tone, and the label's. */
13
14
  export interface BootstrapStepStyle {
14
15
  icon: string
16
+ spin: boolean
15
17
  iconClass: string
16
18
  labelClass: string
17
19
  }
@@ -24,36 +26,43 @@ export interface BootstrapStepStyle {
24
26
  export const BOOTSTRAP_STEP_STYLE: Record<BootstrapStepState, BootstrapStepStyle> = {
25
27
  pending: {
26
28
  icon: 'i-lucide-circle',
29
+ spin: false,
27
30
  iconClass: 'text-dimmed',
28
31
  labelClass: 'text-dimmed',
29
32
  },
30
33
  running: {
31
34
  icon: 'i-lucide-loader-circle',
32
- iconClass: 'animate-spin text-app-warning-400',
35
+ spin: true,
36
+ iconClass: 'text-app-warning-400',
33
37
  labelClass: 'text-app-warning-100',
34
38
  },
35
39
  awaiting_review: {
36
40
  icon: 'i-lucide-user-check',
41
+ spin: false,
37
42
  iconClass: 'text-app-warning-400',
38
43
  labelClass: 'text-app-warning-100',
39
44
  },
40
45
  done: {
41
46
  icon: 'i-lucide-check-circle-2',
47
+ spin: false,
42
48
  iconClass: 'text-app-success-400',
43
49
  labelClass: 'text-muted',
44
50
  },
45
51
  failed: {
46
52
  icon: 'i-lucide-alert-triangle',
53
+ spin: false,
47
54
  iconClass: 'text-app-error-400',
48
55
  labelClass: 'text-app-error-200',
49
56
  },
50
57
  stopped: {
51
58
  icon: 'i-lucide-circle-stop',
59
+ spin: false,
52
60
  iconClass: 'text-muted',
53
61
  labelClass: 'text-toned',
54
62
  },
55
63
  unknown: {
56
64
  icon: 'i-lucide-help-circle',
65
+ spin: false,
57
66
  iconClass: 'text-muted',
58
67
  labelClass: 'text-muted',
59
68
  },
@@ -341,14 +341,27 @@ function describeUnhandledSkipReason(reason: never): string {
341
341
  }
342
342
 
343
343
  /**
344
- * Tailwind classes for a subtask-item status icon. An in-progress item spins only
345
- * while the run is live: once the run has failed, a step left mid-flight (its item
346
- * state still `in_progress`) keeps its colour but stops spinning, matching the frozen
347
- * failure card. Completed items are emerald, everything else muted.
344
+ * Whether a subtask-item status icon spins (`<Spinner :spinning>`). An in-progress item spins only
345
+ * while the run is live: once the run has failed, a step left mid-flight (its item state still
346
+ * `in_progress`) keeps its colour but stops spinning, matching the frozen failure card.
347
+ */
348
+ export function subtaskIconSpins(status: string, runFailed: boolean): boolean {
349
+ return status === 'in_progress' && !runFailed
350
+ }
351
+
352
+ /** The tone of a subtask-item status icon: in-progress is primary, completed is emerald, the rest
353
+ * muted. Pair it with {@link subtaskIconSpins}. */
354
+ export function subtaskIconTone(status: string): string {
355
+ if (status === 'in_progress') return 'text-primary'
356
+ return status === 'completed' ? 'text-app-success-400' : 'text-dimmed'
357
+ }
358
+
359
+ /**
360
+ * Tone plus a hand-written spin, for `board/nodes/TaskPipelineMini.vue` alone. That file is held
361
+ * out of the feedback-states slice (#2252) while the task-card rework rewrites it; when it moves
362
+ * to `<Spinner :spinning="subtaskIconSpins(...)">`, delete this and its `PENDING` entry in
363
+ * `scripts/check-frontend-feedback.mjs`.
348
364
  */
349
365
  export function subtaskIconClass(status: string, runFailed: boolean): string[] {
350
- return [
351
- status === 'in_progress' ? (runFailed ? 'text-primary' : 'animate-spin text-primary') : '',
352
- status === 'completed' ? 'text-app-success-400' : 'text-dimmed',
353
- ]
366
+ return [subtaskIconTone(status), subtaskIconSpins(status, runFailed) ? 'animate-spin' : '']
354
367
  }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Block } from '~/types/domain'
3
+ import { LANE_BY_REASON, LANE_REASONS, type LaneReason } from '~/utils/swimlanes'
4
+ import { assembleQueue, QUEUE_SECTION_BY_REASON, type QueueEntry } from '~/utils/queueSections'
5
+ import type { DoneLaneCaps } from '~/utils/swimlanes'
6
+
7
+ // The queue reads the frame lanes' verdict and must never contradict it. The one place it
8
+ // splits differently (a PR waiting to merge gets its own column) is a regrouping INSIDE the
9
+ // frame's `needs_you` lane, never a move across lanes.
10
+
11
+ const NOW = 1_800_000_000_000
12
+ const DAY = 86_400_000
13
+ /** The caps a workspace sets for its frames' Done lane, which the queue applies too. */
14
+ const CAPS: DoneLaneCaps = { maxItems: 20, retentionDays: 14 }
15
+
16
+ function entry(id: string, reason: LaneReason, overrides: Partial<QueueEntry> = {}): QueueEntry {
17
+ return {
18
+ task: { id, title: id, status: 'ready', level: 'task' } as Block,
19
+ reason,
20
+ order: 0,
21
+ activityAt: null,
22
+ waitingSince: null,
23
+ moduleName: null,
24
+ initiativeName: null,
25
+ epicName: null,
26
+ service: { id: 'svc', title: 'Service' },
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ describe('QUEUE_SECTION_BY_REASON', () => {
32
+ it('files every reason, and never across the lane the frame put it in', () => {
33
+ const laneOfSection = {
34
+ needs_you: 'needs_you',
35
+ ready_to_merge: 'needs_you',
36
+ in_flight: 'in_progress',
37
+ waiting: 'not_started',
38
+ done: 'done',
39
+ } as const
40
+ for (const reason of LANE_REASONS) {
41
+ expect(laneOfSection[QUEUE_SECTION_BY_REASON[reason]], reason).toBe(LANE_BY_REASON[reason])
42
+ }
43
+ })
44
+ })
45
+
46
+ describe('assembleQueue', () => {
47
+ it('puts a decision, a failure and a parked run in Needs you, and a waiting PR beside it', () => {
48
+ const { sections } = assembleQueue(
49
+ [
50
+ entry('decide', 'decision'),
51
+ entry('broken', 'failed'),
52
+ entry('parked', 'parked'),
53
+ entry('pr', 'pr_awaiting_merge'),
54
+ ],
55
+ NOW,
56
+ CAPS,
57
+ )
58
+ expect(sections.needs_you.map((e) => e.task.id).sort()).toEqual(['broken', 'decide', 'parked'])
59
+ expect(sections.ready_to_merge.map((e) => e.task.id)).toEqual(['pr'])
60
+ })
61
+
62
+ it('orders Needs you as the frame lane does: broken first, then the longest wait', () => {
63
+ const { sections } = assembleQueue(
64
+ [
65
+ entry('recent', 'decision', { waitingSince: NOW - 1000, order: 0 }),
66
+ entry('old', 'decision', { waitingSince: NOW - 5000, order: 1 }),
67
+ entry('broken', 'failed', { order: 2 }),
68
+ ],
69
+ NOW,
70
+ CAPS,
71
+ )
72
+ expect(sections.needs_you.map((e) => e.task.id)).toEqual(['broken', 'old', 'recent'])
73
+ })
74
+
75
+ it("applies the frames' Done-lane window: newest first, undated last, older ones left out", () => {
76
+ const merged = (id: string, completedAt: number | undefined) =>
77
+ entry(id, 'merged', {
78
+ task: { id, title: id, status: 'done', level: 'task', completedAt } as Block,
79
+ })
80
+ const queue = assembleQueue(
81
+ [
82
+ merged('yesterday', NOW - DAY),
83
+ merged('today', NOW - 1000),
84
+ merged('ancient', NOW - (CAPS.retentionDays! + 1) * DAY),
85
+ merged('undated', undefined),
86
+ ],
87
+ NOW,
88
+ CAPS,
89
+ )
90
+ expect(queue.sections.done.map((e) => e.task.id)).toEqual(['today', 'yesterday', 'undated'])
91
+ expect(queue.doneInWindow).toBe(3)
92
+ })
93
+
94
+ it('caps the Done list but never the count above it', () => {
95
+ const many = Array.from({ length: CAPS.maxItems + 3 }, (_, i) =>
96
+ entry(`m${i}`, 'merged', {
97
+ order: i,
98
+ task: {
99
+ id: `m${i}`,
100
+ title: '',
101
+ status: 'done',
102
+ level: 'task',
103
+ completedAt: NOW - i,
104
+ } as Block,
105
+ }),
106
+ )
107
+ const queue = assembleQueue(many, NOW, CAPS)
108
+ expect(queue.sections.done).toHaveLength(CAPS.maxItems)
109
+ expect(queue.doneInWindow).toBe(many.length)
110
+ })
111
+ })