@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.
- package/README.md +92 -4
- package/app/app.config.ts +7 -0
- package/app/components/assistant/AssistantModal.vue +2 -5
- package/app/components/auth/AuthGate.vue +2 -1
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +14 -15
- package/app/components/binaryOutput/StoredAssetView.vue +1 -3
- package/app/components/board/AddTaskModal.vue +3 -10
- package/app/components/board/AgentFailureCard.vue +4 -5
- package/app/components/board/AgentStopButton.vue +5 -7
- package/app/components/board/BoardCanvas.vue +31 -30
- package/app/components/board/CreateInitiativeModal.vue +2 -4
- package/app/components/board/RecurringPipelineModal.vue +2 -7
- package/app/components/board/nodes/AgentChip.vue +3 -2
- package/app/components/board/nodes/BlockNode.vue +14 -11
- package/app/components/board/nodes/InitiativeCard.vue +6 -3
- package/app/components/board/nodes/TaskCard.vue +45 -212
- package/app/components/bootstrap/AdoptionReviewModal.vue +2 -5
- package/app/components/bootstrap/BootstrapModal.vue +6 -15
- package/app/components/bootstrap/BootstrapRunSteps.vue +3 -1
- package/app/components/brainstorm/BrainstormWindow.vue +32 -26
- package/app/components/bugFishing/BugFishingWindow.vue +15 -13
- package/app/components/clarity/ClarityReviewWindow.vue +31 -26
- package/app/components/common/ClarificationItem.vue +3 -1
- package/app/components/common/EmptyState.vue +41 -19
- package/app/components/common/InterviewGateNotice.vue +3 -1
- package/app/components/common/Spinner.vue +28 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +5 -5
- package/app/components/docs/DocInterviewWindow.vue +6 -6
- package/app/components/documents/ContextDocumentPicker.vue +2 -2
- package/app/components/documents/DocumentImportModal.vue +9 -8
- package/app/components/documents/DocumentSourceConnectModal.vue +10 -10
- package/app/components/documents/DocumentTemplatesModal.vue +13 -7
- package/app/components/documents/RepoContextDocPicker.vue +2 -2
- package/app/components/documents/SpawnPreviewModal.vue +4 -6
- package/app/components/documents/StartFromDesignModal.vue +2 -1
- package/app/components/documents/TaskContextDocs.vue +4 -5
- package/app/components/environments/EnvironmentStatusPanel.vue +11 -8
- package/app/components/environments/steps/EnvPickStep.vue +7 -3
- package/app/components/environments/steps/EnvPreflightStep.vue +6 -5
- package/app/components/focus/BlockFocusView.vue +6 -5
- package/app/components/followUp/FollowUpWindow.vue +7 -9
- package/app/components/forkDecision/ForkDecisionWindow.vue +9 -8
- package/app/components/foundational/FoundationalServiceCatalogList.vue +9 -5
- package/app/components/foundational/FoundationalServiceRegistry.vue +11 -7
- package/app/components/foundational/FoundationalServiceSources.vue +20 -22
- package/app/components/foundational/FoundationalSuppressions.vue +2 -2
- package/app/components/foundational/ServiceCatalogConnection.vue +15 -15
- package/app/components/fragments/FragmentLibraryManager.vue +36 -34
- package/app/components/fragments/FragmentSelector.vue +7 -3
- package/app/components/gates/GateResultView.vue +6 -9
- package/app/components/github/AddServiceFromRepoModal.vue +6 -10
- package/app/components/github/GitHubConnect.vue +10 -17
- package/app/components/github/GitHubPanel.vue +44 -47
- package/app/components/github/RepoTreeBrowser.vue +9 -5
- package/app/components/guidedReview/GuidedReviewModal.vue +16 -6
- package/app/components/guidedReview/GuidedReviewOverview.vue +2 -1
- package/app/components/guidedReview/GuidedReviewPicker.vue +10 -7
- package/app/components/guidedReview/GuidedReviewThread.vue +9 -4
- package/app/components/humanTest/HumanTestWindow.vue +2 -1
- package/app/components/initiative/InitiativePlanningWindow.vue +6 -6
- package/app/components/initiative/InitiativeTrackerWindow.vue +11 -11
- package/app/components/judge/JudgeResultView.vue +2 -7
- package/app/components/kaizen/KaizenPanel.vue +20 -5
- package/app/components/kaizen/KaizenStepStatus.vue +2 -1
- package/app/components/layout/AccountAuditLog.vue +11 -3
- package/app/components/layout/AccountDeploymentSettings.vue +17 -62
- package/app/components/layout/AccountFailureKindRules.vue +6 -5
- package/app/components/layout/AccountModelPolicySettings.vue +2 -6
- package/app/components/layout/AccountPlatformAlertSettings.vue +5 -13
- package/app/components/layout/AccountRiskPolicySettings.vue +13 -14
- package/app/components/layout/AccountRunCredentialSettings.vue +2 -2
- package/app/components/layout/AccountTeamSettings.vue +12 -9
- package/app/components/layout/BoardSwitcher.vue +2 -2
- package/app/components/layout/BoardToolbar.vue +27 -37
- package/app/components/layout/BoardTopOverlays.vue +8 -2
- package/app/components/layout/CommandBar.vue +7 -3
- package/app/components/layout/ConnectionStatusBanner.vue +2 -1
- package/app/components/layout/IntegrationsHub.vue +7 -3
- package/app/components/layout/ModelProvidersHub.vue +7 -3
- package/app/components/layout/NotificationsInbox.vue +3 -11
- package/app/components/layout/SideBar.vue +5 -0
- package/app/components/layout/WorkViewSwitcher.vue +64 -0
- package/app/components/layout/WorkspaceMembersSettings.vue +12 -5
- package/app/components/media/ArtifactLightbox.vue +4 -3
- package/app/components/notifications/NotificationSettingsPanel.vue +4 -11
- package/app/components/observability/ToolCallList.vue +12 -17
- package/app/components/panels/AgentStepDetail.vue +5 -5
- package/app/components/panels/GenericStructuredResultView.vue +6 -9
- package/app/components/panels/InspectorPanel.vue +2 -1
- package/app/components/panels/MergerResultView.vue +5 -6
- package/app/components/panels/ObservabilityPanel.vue +26 -41
- package/app/components/panels/OperatorDashboardPanel.vue +29 -17
- package/app/components/panels/ReportsPanel.vue +17 -11
- package/app/components/panels/ReportsSpendBreakdown.vue +7 -3
- package/app/components/panels/RunDetailLoadState.vue +4 -3
- package/app/components/panels/StepContainerStatus.vue +4 -3
- package/app/components/panels/StepDelegatedStatus.vue +3 -2
- package/app/components/panels/StepMetadataCard.vue +6 -8
- package/app/components/panels/StepRunMeta.vue +2 -5
- package/app/components/panels/inspector/ContainerSummary.vue +7 -1
- package/app/components/panels/inspector/EpicChildren.vue +7 -3
- package/app/components/panels/inspector/FrontendConfig.vue +7 -3
- package/app/components/panels/inspector/InitiativeInspector.vue +5 -2
- package/app/components/panels/inspector/ServiceConnections.vue +7 -3
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +2 -6
- package/app/components/panels/inspector/ServiceTestSecrets.vue +2 -6
- package/app/components/panels/inspector/ServiceTestingContext.vue +2 -6
- package/app/components/panels/inspector/ServiceValidationConfig.vue +38 -39
- package/app/components/panels/inspector/TaskExecution.vue +5 -6
- package/app/components/panels/inspector/TaskRunSettings.vue +7 -3
- package/app/components/panels/inspector/TaskStructure.vue +0 -1
- package/app/components/pipeline/AgentPromptEditor.vue +25 -18
- package/app/components/pipeline/PipelineBuilder.vue +14 -24
- package/app/components/pipeline/PipelineProgress.vue +16 -17
- package/app/components/prReview/PrReviewPhaseBadge.vue +3 -2
- package/app/components/prReview/PrReviewWindow.vue +16 -20
- package/app/components/providers/ApiKeysSection.vue +2 -6
- package/app/components/providers/PersonalCredentialModal.vue +2 -6
- package/app/components/providers/PersonalSubscriptionSection.vue +3 -7
- package/app/components/providers/VendorCredentialsModal.vue +2 -6
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +10 -6
- package/app/components/queue/QueueCard.vue +269 -0
- package/app/components/queue/QueueView.vue +192 -0
- package/app/components/ralph/RalphLoopResultView.vue +2 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +44 -39
- package/app/components/sandbox/SandboxPanel.vue +37 -25
- package/app/components/settings/AccountSettingsPanel.vue +6 -3
- package/app/components/settings/ApiTokensPanel.vue +2 -6
- package/app/components/settings/BudgetSettings.vue +2 -2
- package/app/components/settings/CapabilityCredentialsPanel.vue +2 -6
- package/app/components/settings/CloudflareHandlerSection.vue +3 -7
- package/app/components/settings/ConsensusGroupsSection.vue +7 -3
- package/app/components/settings/CustomManifestTypeEditor.vue +7 -3
- package/app/components/settings/DefaultProvisionTypeSection.vue +2 -2
- package/app/components/settings/InfraHandlersConfigurator.vue +8 -11
- package/app/components/settings/IssueTrackerPanel.vue +9 -9
- package/app/components/settings/LocalContainerPoolSettings.vue +2 -6
- package/app/components/settings/LocalModelEndpointsPanel.vue +5 -7
- package/app/components/settings/McpAuthorizeScreen.vue +8 -3
- package/app/components/settings/McpOAuthCallbackScreen.vue +2 -1
- package/app/components/settings/ModelConfigurationPanel.vue +45 -17
- package/app/components/settings/ObservabilityConnectionPanel.vue +4 -15
- package/app/components/settings/OpenRouterCatalogPanel.vue +8 -24
- package/app/components/settings/PackageRegistriesPanel.vue +2 -6
- package/app/components/settings/ProviderConnectionTab.vue +3 -7
- package/app/components/settings/RiskPolicyPanel.vue +4 -16
- package/app/components/settings/SharedStacksPanel.vue +8 -18
- package/app/components/settings/TaskTypeSuppressionsPanel.vue +10 -6
- package/app/components/settings/UsageSettings.vue +11 -2
- package/app/components/settings/UserSecretsSection.vue +5 -7
- package/app/components/settings/WorkspaceMetadataSettings.vue +9 -9
- package/app/components/settings/WorkspaceSettingsPanel.vue +3 -11
- package/app/components/skills/ReviewSkillQueue.vue +7 -3
- package/app/components/skills/SkillLibraryManager.vue +20 -18
- package/app/components/slack/SlackPanel.vue +5 -8
- package/app/components/spec/ServiceSpecWindow.vue +32 -29
- package/app/components/tasks/BugHuntModal.vue +23 -19
- package/app/components/tasks/ContextIssuePicker.vue +2 -2
- package/app/components/tasks/TaskContextIssues.vue +4 -5
- package/app/components/tasks/TaskImportModal.vue +10 -8
- package/app/components/tasks/TaskSourceConnectModal.vue +11 -11
- package/app/components/testing/TestReportWindow.vue +6 -12
- package/app/components/tutorial/TutorialCatalogue.vue +7 -3
- package/app/components/tutorial/TutorialOverlay.vue +2 -1
- package/app/components/tutorial/TutorialPrompt.vue +7 -3
- package/app/components/vcs/GitLabConnect.vue +2 -6
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +9 -4
- package/app/composables/toastTitleKeys.spec.ts +143 -0
- package/app/composables/useActionToast.spec.ts +71 -0
- package/app/composables/useActionToast.ts +101 -0
- package/app/composables/useBlockDeletion.ts +3 -6
- package/app/composables/useConfirmAction.ts +5 -3
- package/app/composables/useContextLinking.spec.ts +3 -0
- package/app/composables/useContextLinking.ts +11 -16
- package/app/composables/useCopyToClipboard.ts +4 -3
- package/app/composables/useFrameLanes.ts +6 -82
- package/app/composables/useNavContributions.ts +6 -5
- package/app/composables/usePipelineErrorToast.spec.ts +24 -0
- package/app/composables/usePipelineErrorToast.ts +26 -2
- package/app/composables/usePipelineLibraryActions.ts +8 -9
- package/app/composables/useTaskActions.ts +254 -0
- package/app/composables/useTaskLaneClassifier.ts +88 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +31 -0
- package/app/modular/tutorial-tours.ts +10 -0
- package/app/pages/index.vue +42 -8
- package/app/stores/board/context.ts +4 -3
- package/app/stores/board/placement.ts +8 -20
- package/app/stores/board/removal.ts +4 -13
- package/app/stores/board.spec.ts +10 -1
- package/app/stores/board.ts +3 -2
- package/app/stores/workView.ts +51 -0
- package/app/stores/workspaceQueue.ts +148 -0
- package/app/utils/bootstrapSteps.ts +11 -2
- package/app/utils/pipelineRender.ts +21 -8
- package/app/utils/queueSections.spec.ts +111 -0
- package/app/utils/queueSections.ts +117 -0
- package/app/utils/workView.spec.ts +16 -0
- package/app/utils/workView.ts +25 -0
- package/i18n/locales/de.json +73 -32
- package/i18n/locales/en.json +73 -32
- package/i18n/locales/es.json +73 -32
- package/i18n/locales/fr.json +73 -32
- package/i18n/locales/he.json +73 -32
- package/i18n/locales/it.json +73 -32
- package/i18n/locales/ja.json +73 -32
- package/i18n/locales/pl.json +73 -32
- package/i18n/locales/tr.json +73 -32
- package/i18n/locales/uk.json +73 -32
- 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`/`
|
|
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
|
-
|
|
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 `
|
|
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,
|
|
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.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -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')
|
package/app/stores/board.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
345
|
-
* while the run is live: once the run has failed, a step left mid-flight (its item
|
|
346
|
-
*
|
|
347
|
-
|
|
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
|
+
})
|