@cat-factory/app 0.311.3 → 0.313.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 +32 -0
- package/app/components/board/nodes/TaskCard.vue +45 -212
- package/app/components/layout/AiProvidersBanner.vue +3 -2
- package/app/components/layout/BoardToolbar.vue +24 -26
- package/app/components/layout/BoardTopOverlays.vue +8 -2
- package/app/components/layout/GitHubPatBanner.vue +2 -2
- package/app/components/layout/GitHubPatPermissionsBanner.vue +4 -12
- package/app/components/layout/InfraSetupBanner.vue +6 -7
- package/app/components/layout/SideBar.vue +5 -0
- package/app/components/layout/WorkViewSwitcher.vue +64 -0
- package/app/components/queue/QueueCard.vue +269 -0
- package/app/components/queue/QueueView.vue +192 -0
- package/app/components/setup/SetupChecklistPanel.vue +340 -0
- package/app/composables/useFrameLanes.ts +6 -82
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useSetupChecklist.ts +122 -0
- package/app/composables/useSetupRemedies.ts +72 -0
- package/app/composables/useTaskActions.ts +254 -0
- package/app/composables/useTaskLaneClassifier.ts +88 -0
- package/app/modular/nav-contributions.spec.ts +6 -0
- package/app/modular/nav-contributions.ts +56 -0
- package/app/modular/tutorial-tours.ts +10 -0
- package/app/pages/index.vue +43 -6
- package/app/stores/github/context.ts +1 -0
- package/app/stores/github/probe.ts +7 -3
- package/app/stores/github.ts +9 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/stores/workView.ts +51 -0
- package/app/stores/workspaceQueue.ts +148 -0
- package/app/utils/queueSections.spec.ts +111 -0
- package/app/utils/queueSections.ts +117 -0
- package/app/utils/setupChecklist.spec.ts +184 -0
- package/app/utils/setupChecklist.ts +229 -0
- package/app/utils/workView.spec.ts +16 -0
- package/app/utils/workView.ts +25 -0
- package/i18n/locales/de.json +132 -3
- package/i18n/locales/en.json +132 -3
- package/i18n/locales/es.json +132 -3
- package/i18n/locales/fr.json +132 -3
- package/i18n/locales/he.json +132 -3
- package/i18n/locales/it.json +132 -3
- package/i18n/locales/ja.json +132 -3
- package/i18n/locales/pl.json +132 -3
- package/i18n/locales/tr.json +132 -3
- package/i18n/locales/uk.json +132 -3
- package/package.json +1 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { defaultBuildPipelineId } from '@cat-factory/contracts'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a task offers a human to DO, and the handlers that do it.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the two surfaces that put a task's action within one click: the board's task card
|
|
10
|
+
* and the queue's card. Both must offer the same Start (same pipeline), the same Resolve (same
|
|
11
|
+
* gate) and the same Merge (same confirm), or a reader would learn two products.
|
|
12
|
+
*/
|
|
13
|
+
export function useTaskActions(taskId: () => string) {
|
|
14
|
+
const board = useBoardStore()
|
|
15
|
+
const execution = useExecutionStore()
|
|
16
|
+
const ui = useUiStore()
|
|
17
|
+
const reviews = useReviewStage()
|
|
18
|
+
const toast = useActionToast()
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
const { confirm } = useConfirm()
|
|
21
|
+
const access = useWorkspaceAccess()
|
|
22
|
+
|
|
23
|
+
const task = computed<Block | undefined>(() => board.getBlock(taskId()))
|
|
24
|
+
|
|
25
|
+
// This Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
|
|
26
|
+
// request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
|
|
27
|
+
// their role means this button opens a pull request and merges nothing, and a card that said so
|
|
28
|
+
// only after the fact would leave that to be discovered from a run that stops at the merge.
|
|
29
|
+
const { forcedFor } = useDryRunPolicy()
|
|
30
|
+
const sandboxed = computed(() => forcedFor(taskId()))
|
|
31
|
+
|
|
32
|
+
/** Deps that haven't merged yet: these block this task from running. */
|
|
33
|
+
const unmet = computed(() => board.unmetDeps(taskId()))
|
|
34
|
+
const runnable = computed(() => board.isRunnable(taskId()))
|
|
35
|
+
|
|
36
|
+
// Start and Merge both need `runs.execute`. A read-only member sees them disabled with the reason,
|
|
37
|
+
// the way the shared failure banner treats Retry, rather than pressing a button the server
|
|
38
|
+
// refuses. Asked once here so the two cards cannot gate them differently.
|
|
39
|
+
const canExecute = computed(() => access.canExecuteRuns.value)
|
|
40
|
+
const executeDeniedTitle = computed(() =>
|
|
41
|
+
canExecute.value ? undefined : t('access.noRunExecute'),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const pipelineFor = useStartPipelineResolver()
|
|
45
|
+
const defaultPipeline = computed(() => pipelineFor(task.value))
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the outcome summary has something to show, asked of the SAME reduction the window
|
|
49
|
+
* renders and the inspector's button gates on: a surface that offered "read the result" on a
|
|
50
|
+
* task whose every section says "nothing here" would teach people the surface is empty. A task
|
|
51
|
+
* marked done by hand, with no pull request and no run, is that task.
|
|
52
|
+
*/
|
|
53
|
+
const outcomeReadable = computed(() => {
|
|
54
|
+
const block = task.value
|
|
55
|
+
if (!block) return false
|
|
56
|
+
return hasOutcomeToShow(
|
|
57
|
+
composeRunOutcome({ block, instance: execution.getInstance(block.executionId) ?? null }),
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function openOutcome() {
|
|
62
|
+
ui.openOutcome(taskId(), task.value?.executionId ?? null)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Optimistic "Start": flip the button into a spinning "Starting…" state the instant it's
|
|
66
|
+
// clicked, before the server confirms. The button naturally unmounts once the stream pushes the
|
|
67
|
+
// block into `in_progress`; if the start call faults we revert and surface a toast.
|
|
68
|
+
const starting = ref(false)
|
|
69
|
+
|
|
70
|
+
async function start() {
|
|
71
|
+
if (!runnable.value) {
|
|
72
|
+
// A refusal decided in the browser, in translated copy: the `error` tone (`useActionToast`).
|
|
73
|
+
toast.error('board.task.blockedByDependenciesTitle', {
|
|
74
|
+
description: t('board.task.waitingOn', {
|
|
75
|
+
deps: unmet.value.map((d) => d.title).join(', '),
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const pipeline = defaultPipeline.value
|
|
81
|
+
if (!pipeline) {
|
|
82
|
+
toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
starting.value = true
|
|
86
|
+
// false ⇒ the run never started (the user cancelled the personal-password prompt, or the
|
|
87
|
+
// start was refused: the store surfaces the actionable toast itself). Revert the optimistic
|
|
88
|
+
// state; on success the button unmounts once the stream pushes in_progress.
|
|
89
|
+
const started = await execution.start(taskId(), pipeline)
|
|
90
|
+
if (started) {
|
|
91
|
+
// Confirm the (optimistic) start landed: the button unmounts once the stream pushes
|
|
92
|
+
// in_progress, so without this the successful action gives no feedback. A sandboxed start
|
|
93
|
+
// says so here rather than borrowing the live wording: this is the moment the reader learns
|
|
94
|
+
// what they just started, and the two runs differ in what they will end up doing.
|
|
95
|
+
// A sandboxed run is `warning`: it ran, and its outcome differs in a way the reader should
|
|
96
|
+
// know (it opens a pull request and merges nothing), so it stays until closed.
|
|
97
|
+
if (sandboxed.value) {
|
|
98
|
+
toast.warning('board.dryRunToast.title', {
|
|
99
|
+
description: t('board.dryRunToast.body', { name: pipeline.name }),
|
|
100
|
+
})
|
|
101
|
+
} else {
|
|
102
|
+
toast.success('board.task.startedToast.title', {
|
|
103
|
+
description: t('board.task.startedToast.body', { name: pipeline.name }),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
starting.value = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function merge() {
|
|
112
|
+
// Merging a PR into its base is consequential and effectively irreversible: gate it behind a
|
|
113
|
+
// confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
|
|
114
|
+
const ok = await confirm({
|
|
115
|
+
title: t('board.task.mergeConfirm.title'),
|
|
116
|
+
description: t('board.task.mergeConfirm.body'),
|
|
117
|
+
confirmLabel: t('board.task.mergeConfirm.confirm'),
|
|
118
|
+
icon: 'i-lucide-git-merge',
|
|
119
|
+
})
|
|
120
|
+
if (!ok) return
|
|
121
|
+
await execution.mergePr(taskId())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A `blocked` task is waiting on a human for one of two reasons, an agent-raised decision OR an
|
|
125
|
+
// approval gate, and both must surface (a failed run is shown separately by the
|
|
126
|
+
// AgentFailureCard). Read off the per-block index rather than scanning the workspace-wide list:
|
|
127
|
+
// this computed is mounted once per card and invalidated by every execution event.
|
|
128
|
+
const pendingDecision = computed(() => execution.decisionsByBlock.get(taskId())?.[0])
|
|
129
|
+
// The async stage an iterative reviewer gate (requirements-review / clarity-review) is mid-cycle
|
|
130
|
+
// in (folding the answers, then re-reviewing), or null. While set, the gate needs NO human
|
|
131
|
+
// action, so its approval is suppressed below and a working indicator shows instead.
|
|
132
|
+
const reviewStage = computed(() => reviews.stageForBlock(taskId()))
|
|
133
|
+
const reviewStageLabel = computed(() =>
|
|
134
|
+
reviewStage.value === 'incorporating'
|
|
135
|
+
? t('board.task.incorporatingAnswers')
|
|
136
|
+
: reviewStage.value === 'reviewing'
|
|
137
|
+
? t('board.task.reReviewing')
|
|
138
|
+
: reviewStage.value === 'recommending'
|
|
139
|
+
? t('board.task.recommending')
|
|
140
|
+
: null,
|
|
141
|
+
)
|
|
142
|
+
const pendingApproval = computed(() => {
|
|
143
|
+
// A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
|
|
144
|
+
// background work, not awaiting a human: skip it, and offer the first gate a human IS
|
|
145
|
+
// awaiting. The lane classifier counts any such gate, so a card filed under Needs you for an
|
|
146
|
+
// approval always has that approval to open.
|
|
147
|
+
return execution.approvalsByBlock
|
|
148
|
+
.get(taskId())
|
|
149
|
+
?.find((a) => !reviews.isBackground(a.agentKind, taskId()))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What this blocked task actually needs from a human: drives the card's label, pulse and
|
|
154
|
+
* action. Decision takes precedence over approval (a step never holds both at once; this is
|
|
155
|
+
* just a stable order). Null when nothing is pending.
|
|
156
|
+
*/
|
|
157
|
+
const attention = computed<{
|
|
158
|
+
label: string
|
|
159
|
+
icon: string
|
|
160
|
+
action: string
|
|
161
|
+
open: () => void
|
|
162
|
+
} | null>(() => {
|
|
163
|
+
const d = pendingDecision.value
|
|
164
|
+
if (d)
|
|
165
|
+
return {
|
|
166
|
+
label: t('board.task.decisionNeeded'),
|
|
167
|
+
icon: 'i-lucide-circle-help',
|
|
168
|
+
action: t('board.task.resolve'),
|
|
169
|
+
open: () => ui.openDecision(d.instanceId, d.decision.id),
|
|
170
|
+
}
|
|
171
|
+
const a = pendingApproval.value
|
|
172
|
+
if (a)
|
|
173
|
+
return {
|
|
174
|
+
label: t('board.task.approvalNeeded'),
|
|
175
|
+
icon: 'i-lucide-shield-check',
|
|
176
|
+
action: t('board.task.approve'),
|
|
177
|
+
open: () => ui.openApprovalDetail(a.instanceId, a.approval.id),
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
task,
|
|
184
|
+
sandboxed,
|
|
185
|
+
unmet,
|
|
186
|
+
runnable,
|
|
187
|
+
canExecute,
|
|
188
|
+
executeDeniedTitle,
|
|
189
|
+
defaultPipeline,
|
|
190
|
+
outcomeReadable,
|
|
191
|
+
openOutcome,
|
|
192
|
+
starting,
|
|
193
|
+
start,
|
|
194
|
+
merge,
|
|
195
|
+
pendingDecision,
|
|
196
|
+
reviewStage,
|
|
197
|
+
reviewStageLabel,
|
|
198
|
+
attention,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The pipeline a plain "Start" runs, as the descriptor `execution.start` takes. */
|
|
203
|
+
export interface StartPipeline {
|
|
204
|
+
id: string
|
|
205
|
+
name: string
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the pipeline a plain "Start" uses for a task. A function rather than a computed so
|
|
210
|
+
* a caller can ask it about any task, not only about the one its card shows.
|
|
211
|
+
*/
|
|
212
|
+
export function useStartPipelineResolver() {
|
|
213
|
+
const pipelines = usePipelinesStore()
|
|
214
|
+
const uiMode = useUiModeStore()
|
|
215
|
+
const { t } = useI18n()
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
|
|
219
|
+
* INTERFACE MODE defaults to (`defaultBuildPipelineId`: the fixed Standard build in basic mode,
|
|
220
|
+
* the Adaptive one in advanced). The workspace's positional first pipeline remains the last
|
|
221
|
+
* resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
|
|
222
|
+
* retired it).
|
|
223
|
+
*
|
|
224
|
+
* A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
|
|
225
|
+
* this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
|
|
226
|
+
* library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
|
|
227
|
+
* task can legitimately be pinned to one: the docs-refresh preset spawns its tasks onto
|
|
228
|
+
* `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
|
|
229
|
+
* fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
|
|
230
|
+
* an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
|
|
231
|
+
* show is still the task's answer, and the backend resolves the id for the run.
|
|
232
|
+
*/
|
|
233
|
+
return (task: Pick<Block, 'pipelineId'> | undefined): StartPipeline | undefined => {
|
|
234
|
+
const pinnedId = task?.pipelineId
|
|
235
|
+
if (pinnedId) {
|
|
236
|
+
return (
|
|
237
|
+
pipelines.getPipeline(pinnedId) ?? {
|
|
238
|
+
id: pinnedId,
|
|
239
|
+
// The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
|
|
240
|
+
// pin still names itself here; the generic label covers a pin to something this build's
|
|
241
|
+
// catalog does not know at all.
|
|
242
|
+
name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
// No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
|
|
247
|
+
// an operator who named one said something a tier cannot overrule.
|
|
248
|
+
const declared = pipelines.declaredDefaultId('interactive')
|
|
249
|
+
return (
|
|
250
|
+
pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
|
|
251
|
+
pipelines.pipelines[0]
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Block } from '~/types/domain'
|
|
2
|
+
import { runActivityAt, runWaitingSince, type LaneTaskEntry } from '~/utils/laneSort'
|
|
3
|
+
import { classifyTask, type TaskLane } from '~/utils/swimlanes'
|
|
4
|
+
|
|
5
|
+
/** A lane entry plus the lane it was classified into. */
|
|
6
|
+
export interface ClassifiedEntry {
|
|
7
|
+
readonly entry: LaneTaskEntry
|
|
8
|
+
readonly lane: TaskLane
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The store-facing half of `classifyTask`: resolve one task's lane, reason and rendered entry.
|
|
13
|
+
*
|
|
14
|
+
* Shared by the two surfaces that lay tasks out in lanes, a service frame's swimlanes
|
|
15
|
+
* (`useFrameLanes`) and the workspace-wide queue (`useWorkspaceQueueStore`), so both state the SAME
|
|
16
|
+
* claim about a task. A card in "Needs you" on the board and in "In flight" in the queue would
|
|
17
|
+
* make the reader arbitrate between two answers to one question.
|
|
18
|
+
*
|
|
19
|
+
* Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
|
|
20
|
+
* of its own: `useFrameLanes` runs one instance per mounted frame, so deriving a workspace-wide
|
|
21
|
+
* fact here makes a board with n frames pay for it n times on every change. The review-debt map
|
|
22
|
+
* (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
|
|
23
|
+
* workspace's open notifications, and it belongs on the store that owns its input.
|
|
24
|
+
*/
|
|
25
|
+
export function useTaskLaneClassifier() {
|
|
26
|
+
const board = useBoardStore()
|
|
27
|
+
const execution = useExecutionStore()
|
|
28
|
+
const agentRuns = useAgentRunsStore()
|
|
29
|
+
const notifications = useNotificationsStore()
|
|
30
|
+
const reviews = useReviewStage()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The module a task belongs to: the module BLOCK's title when it already lives in one, else
|
|
34
|
+
* the module it DECLARES. The engine only materialises the block on merge
|
|
35
|
+
* (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
|
|
36
|
+
* "no module" while its own card names one.
|
|
37
|
+
*/
|
|
38
|
+
function moduleNameOf(task: Block): string | null {
|
|
39
|
+
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
40
|
+
if (parent?.level === 'module') return parent.title
|
|
41
|
+
return task.moduleName?.trim() || null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One task's lane, reason and rendered entry. `order` is the caller's stable tiebreak. */
|
|
45
|
+
function classify(task: Block, order: number): ClassifiedEntry {
|
|
46
|
+
const run = execution.getByBlock(task.id) ?? null
|
|
47
|
+
const decisions = execution.decisionsByBlock.get(task.id) ?? []
|
|
48
|
+
const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
|
|
49
|
+
// The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
|
|
50
|
+
// holds a pending approval while the driver folds answers in, and nobody is waiting on it.
|
|
51
|
+
const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
|
|
52
|
+
|
|
53
|
+
const { lane, reason } = classifyTask({
|
|
54
|
+
status: task.status,
|
|
55
|
+
// Read from the coarse per-block summary, which also covers a bootstrap run.
|
|
56
|
+
runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
|
|
57
|
+
run,
|
|
58
|
+
// A park is background exactly when everything asking was suppressed AND nothing else
|
|
59
|
+
// asks. With no approvals at all it is NOT background: it is a park on a surface this
|
|
60
|
+
// layer cannot name, which `classifyTask` reports as `parked` rather than as work.
|
|
61
|
+
parkIsBackground:
|
|
62
|
+
decisions.length === 0 &&
|
|
63
|
+
humanApprovals.length === 0 &&
|
|
64
|
+
allApprovals.length > humanApprovals.length,
|
|
65
|
+
pendingDecision: decisions.length > 0,
|
|
66
|
+
pendingApproval: humanApprovals.length > 0,
|
|
67
|
+
hasUnmetDeps: board.unmetDeps(task.id).length > 0,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
lane,
|
|
72
|
+
entry: {
|
|
73
|
+
task,
|
|
74
|
+
reason,
|
|
75
|
+
order,
|
|
76
|
+
activityAt: runActivityAt(run),
|
|
77
|
+
waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
|
|
78
|
+
moduleName: moduleNameOf(task),
|
|
79
|
+
initiativeName: task.initiativeId
|
|
80
|
+
? (board.getBlock(task.initiativeId)?.title ?? null)
|
|
81
|
+
: null,
|
|
82
|
+
epicName: board.epicOf(task)?.title ?? null,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { classify }
|
|
88
|
+
}
|
|
@@ -224,6 +224,8 @@ describe('navSlotFilter', () => {
|
|
|
224
224
|
tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
|
|
225
225
|
'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
|
|
226
226
|
'ui-role': 'the way BACK out of the narrowed role',
|
|
227
|
+
'queue-view': 'which view of the board a person works in is their own choice, in every role',
|
|
228
|
+
'board-view': 'the way back to the canvas from the queue view, in every role',
|
|
227
229
|
}
|
|
228
230
|
const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
|
|
229
231
|
expect(intake.sort()).toEqual(Object.keys(REASON).sort())
|
|
@@ -489,6 +491,7 @@ describe('nav grouping helpers', () => {
|
|
|
489
491
|
expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
|
|
490
492
|
const configuration = groups.find((g) => g.group === 'configuration')
|
|
491
493
|
expect(configuration?.items.map((i) => i.id)).toEqual([
|
|
494
|
+
'setup-checklist',
|
|
492
495
|
'workspace-settings',
|
|
493
496
|
'account-settings',
|
|
494
497
|
'operator-dashboard',
|
|
@@ -522,6 +525,9 @@ describe('nav grouping helpers', () => {
|
|
|
522
525
|
'ui-role',
|
|
523
526
|
'tutorial',
|
|
524
527
|
'foundational-services',
|
|
528
|
+
'setup-checklist',
|
|
529
|
+
'queue-view',
|
|
530
|
+
'board-view',
|
|
525
531
|
])
|
|
526
532
|
})
|
|
527
533
|
|
|
@@ -210,6 +210,7 @@ export const NAV_ACTIONS = [
|
|
|
210
210
|
'foundationalServices',
|
|
211
211
|
'mergeThresholds',
|
|
212
212
|
'workspaceSettings',
|
|
213
|
+
'setupChecklist',
|
|
213
214
|
'modelConfiguration',
|
|
214
215
|
'serviceFragmentDefaults',
|
|
215
216
|
'localModels',
|
|
@@ -220,6 +221,8 @@ export const NAV_ACTIONS = [
|
|
|
220
221
|
'tutorial',
|
|
221
222
|
'toggleUiMode',
|
|
222
223
|
'chooseRole',
|
|
224
|
+
'queueView',
|
|
225
|
+
'boardView',
|
|
223
226
|
] as const
|
|
224
227
|
|
|
225
228
|
export type NavActionId = (typeof NAV_ACTIONS)[number]
|
|
@@ -536,6 +539,30 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
536
539
|
keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
|
|
537
540
|
},
|
|
538
541
|
},
|
|
542
|
+
{
|
|
543
|
+
// Every prerequisite for "a task can run and merge", composed from the probes the setup
|
|
544
|
+
// banners already read (issue #2258). First in Configuration, because a workspace that cannot
|
|
545
|
+
// run a task yet needs it before anything else there. Both tiers: getting to green is not an
|
|
546
|
+
// advanced job. Gated on either permission, since its rows open both kinds of surface. Not
|
|
547
|
+
// `intake`: the designer surface never configures the platform, and the setup banners give it
|
|
548
|
+
// no actionable card either. Each row still applies the banners' per-owner rule
|
|
549
|
+
// (`utils/setupAudience.ts`), because holding one of these permissions does not make a caller
|
|
550
|
+
// the owner of every gap.
|
|
551
|
+
id: 'setup-checklist',
|
|
552
|
+
labelKey: 'nav.setupChecklist',
|
|
553
|
+
icon: 'i-lucide-list-checks',
|
|
554
|
+
surfaces: S('sidebar', 'command'),
|
|
555
|
+
gate: (g) => g.canManageIntegrations || g.canManageSettings,
|
|
556
|
+
action: 'setupChecklist',
|
|
557
|
+
testId: 'nav-setup-checklist',
|
|
558
|
+
sidebar: { group: 'configuration', order: 5 },
|
|
559
|
+
command: {
|
|
560
|
+
group: 'workspace',
|
|
561
|
+
order: 120,
|
|
562
|
+
labelKey: 'layout.commandBar.cmd.setupChecklist',
|
|
563
|
+
keywordsKey: 'layout.commandBar.keywords.setupChecklist',
|
|
564
|
+
},
|
|
565
|
+
},
|
|
539
566
|
{
|
|
540
567
|
id: 'model-config',
|
|
541
568
|
labelKey: 'nav.modelConfiguration',
|
|
@@ -737,6 +764,35 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
737
764
|
keywordsKey: 'layout.commandBar.keywords.chooseRole',
|
|
738
765
|
},
|
|
739
766
|
},
|
|
767
|
+
{
|
|
768
|
+
// The board's two views (issue #2258). The sidebar's Views switcher is the everyday route;
|
|
769
|
+
// these make each view reachable by name from the palette too. Every tier and role: which view
|
|
770
|
+
// a person works in is their own choice, and neither view is an override of the other.
|
|
771
|
+
id: 'queue-view',
|
|
772
|
+
labelKey: 'layout.commandBar.cmd.queueView',
|
|
773
|
+
icon: 'i-lucide-list-checks',
|
|
774
|
+
surfaces: S('command'),
|
|
775
|
+
intake: true,
|
|
776
|
+
action: 'queueView',
|
|
777
|
+
command: {
|
|
778
|
+
group: 'workspace',
|
|
779
|
+
order: 130,
|
|
780
|
+
keywordsKey: 'layout.commandBar.keywords.queueView',
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
id: 'board-view',
|
|
785
|
+
labelKey: 'layout.commandBar.cmd.boardView',
|
|
786
|
+
icon: 'i-lucide-map',
|
|
787
|
+
surfaces: S('command'),
|
|
788
|
+
intake: true,
|
|
789
|
+
action: 'boardView',
|
|
790
|
+
command: {
|
|
791
|
+
group: 'workspace',
|
|
792
|
+
order: 131,
|
|
793
|
+
keywordsKey: 'layout.commandBar.keywords.boardView',
|
|
794
|
+
},
|
|
795
|
+
},
|
|
740
796
|
]
|
|
741
797
|
|
|
742
798
|
/**
|
|
@@ -194,6 +194,16 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
194
194
|
titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
|
|
195
195
|
bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
|
|
196
196
|
},
|
|
197
|
+
{
|
|
198
|
+
// A new person lands on the QUEUE view, and every tour moves them to the canvas, so the
|
|
199
|
+
// orientation tour is where they learn the queue exists. The switcher renders in the rail
|
|
200
|
+
// and the expanded sidebar, in every tier and role, so the step needs no `when`.
|
|
201
|
+
id: 'views',
|
|
202
|
+
target: 'work-view-switcher',
|
|
203
|
+
placement: 'right',
|
|
204
|
+
titleKey: 'tutorial.tours.boardBasics.steps.views.title',
|
|
205
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.views.body',
|
|
206
|
+
},
|
|
197
207
|
{
|
|
198
208
|
id: 'sidebar',
|
|
199
209
|
target: 'sidebar',
|
package/app/pages/index.vue
CHANGED
|
@@ -104,6 +104,9 @@ const ModelProvidersHub = defineAsyncView(() => import('~/components/layout/Mode
|
|
|
104
104
|
const PersonalSetupModal = defineAsyncView(
|
|
105
105
|
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
106
106
|
)
|
|
107
|
+
const SetupChecklistPanel = defineAsyncView(
|
|
108
|
+
() => import('~/components/setup/SetupChecklistPanel.vue'),
|
|
109
|
+
)
|
|
107
110
|
const WorkspaceSettingsPanel = defineAsyncView(
|
|
108
111
|
() => import('~/components/settings/WorkspaceSettingsPanel.vue'),
|
|
109
112
|
)
|
|
@@ -159,12 +162,15 @@ const TutorialNudge = defineAsyncView(() => import('~/components/tutorial/Tutori
|
|
|
159
162
|
// The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
|
|
160
163
|
// launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
|
|
161
164
|
const RolePrompt = defineAsyncView(() => import('~/components/layout/RolePrompt.vue'))
|
|
165
|
+
// The queue: the board's second view (issue #2258), loaded the first time someone opens it.
|
|
166
|
+
const QueueView = defineAsyncView(() => import('~/components/queue/QueueView.vue'))
|
|
162
167
|
|
|
163
168
|
const workspace = useWorkspaceStore()
|
|
164
169
|
const github = useGitHubStore()
|
|
165
170
|
const models = useModelsStore()
|
|
166
171
|
const ui = useUiStore()
|
|
167
172
|
const aiReadiness = useAiReadiness()
|
|
173
|
+
const views = useWorkViewStore()
|
|
168
174
|
|
|
169
175
|
// App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
|
|
170
176
|
// for the cheatsheet). Registered ONCE here so a single global listener owns them.
|
|
@@ -176,6 +182,8 @@ onMounted(() => {
|
|
|
176
182
|
// board it names is the one that opens. This is what makes the observability link on an
|
|
177
183
|
// engine-published PR verification report resolve.
|
|
178
184
|
useRunDeepLink()
|
|
185
|
+
// `?view=queue` / `?view=board`: a link that opens one of the board's two views.
|
|
186
|
+
views.consumeDeepLink()
|
|
179
187
|
void workspace.init()
|
|
180
188
|
// Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
|
|
181
189
|
// window pre-seeded with the provisioned connection so the user only pastes the token + saves.
|
|
@@ -401,6 +409,14 @@ useTutorialNudge()
|
|
|
401
409
|
// plus the funnel counters. Unconditional and best-effort: with no accounts or no store wired it
|
|
402
410
|
// simply has nothing to talk to, and the browser-persisted store carries on alone.
|
|
403
411
|
useTutorialSync()
|
|
412
|
+
// Starting a tour switches to the canvas, which every tour walks (the view store holds it for the
|
|
413
|
+
// tour's length), and the canvas is then the view used last.
|
|
414
|
+
watch(
|
|
415
|
+
() => tutorial.touring,
|
|
416
|
+
(touring) => {
|
|
417
|
+
if (touring) views.show('board')
|
|
418
|
+
},
|
|
419
|
+
)
|
|
404
420
|
|
|
405
421
|
// Subscribe to the backend's real-time event stream and (re)connect whenever the
|
|
406
422
|
// active workspace changes. Runs advance durably server-side; progress arrives as
|
|
@@ -462,12 +478,32 @@ watch(
|
|
|
462
478
|
<main class="relative min-w-0 flex-1">
|
|
463
479
|
<BoardCanvas />
|
|
464
480
|
<!-- Toolbar, nav trigger and every advisory banner, in ONE stacked region that owns
|
|
465
|
-
their placement, so no two of them can cover each other.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
481
|
+
their placement, so no two of them can cover each other.
|
|
482
|
+
|
|
483
|
+
The queue view sits OVER the canvas rather than replacing it, so switching back keeps
|
|
484
|
+
the canvas's camera and selection. Below the inspector (z-20), so a task opened from
|
|
485
|
+
the queue shows its panel beside it. The same advisories render IN FLOW above its
|
|
486
|
+
columns, so they push the queue down instead of covering its header.
|
|
487
|
+
|
|
488
|
+
ONE overlay instance serves both views, at the same place in this template: two
|
|
489
|
+
instances would remount every banner on each switch, and a banner whose dismissal
|
|
490
|
+
is its own component state would come back. On the canvas the wrapper is
|
|
491
|
+
`display: contents`, so it adds no box and the overlays float over the canvas as
|
|
492
|
+
before. -->
|
|
493
|
+
<div
|
|
494
|
+
:class="
|
|
495
|
+
views.view === 'queue' ? 'absolute inset-0 z-10 flex flex-col bg-app-950' : 'contents'
|
|
496
|
+
"
|
|
497
|
+
:data-testid="views.view === 'queue' ? 'queue-view-host' : undefined"
|
|
498
|
+
>
|
|
499
|
+
<BoardTopOverlays
|
|
500
|
+
:in-flow="views.view === 'queue'"
|
|
501
|
+
:connected="streamConnected"
|
|
502
|
+
:ever-connected="streamEverConnected"
|
|
503
|
+
:connection-failed="streamConnectionFailed"
|
|
504
|
+
/>
|
|
505
|
+
<QueueView v-if="views.view === 'queue'" class="min-h-0 flex-1" />
|
|
506
|
+
</div>
|
|
471
507
|
<InspectorPanel />
|
|
472
508
|
<!-- Code-split focus view. The fade lives here (not inside the component) so the
|
|
473
509
|
leave animation still plays when `focusBlockId` clears and the v-if unmounts
|
|
@@ -531,6 +567,7 @@ watch(
|
|
|
531
567
|
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
532
568
|
<ModelProvidersHub v-if="ui.modelProvidersOpen" />
|
|
533
569
|
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
570
|
+
<SetupChecklistPanel v-if="ui.setupChecklistOpen" />
|
|
534
571
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
535
572
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
536
573
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
@@ -26,6 +26,7 @@ export interface GitHubStoreContext {
|
|
|
26
26
|
api: ReturnType<typeof useApi>
|
|
27
27
|
workspace: ReturnType<typeof useWorkspaceStore>
|
|
28
28
|
available: Ref<boolean | null>
|
|
29
|
+
unavailableCause: Ref<'disabled' | 'failed' | null>
|
|
29
30
|
connection: Ref<GitHubConnection | null>
|
|
30
31
|
/** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
|
|
31
32
|
connectOptions: Ref<VcsConnectOption[]>
|
|
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
|
|
|
2
2
|
import type { GitHubPatCheck } from '~/types/domain'
|
|
3
3
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
4
4
|
import type { GitHubStoreContext } from '~/stores/github/context'
|
|
5
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The board-load probe: everything the store learns about the deployment's VCS setup in one round
|
|
@@ -37,7 +38,7 @@ export function createGitHubProbe(
|
|
|
37
38
|
ctx: GitHubStoreContext,
|
|
38
39
|
patCheck: Ref<GitHubPatCheck | null>,
|
|
39
40
|
): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
|
|
40
|
-
const { api, workspace, available, connection, connectOptions } = ctx
|
|
41
|
+
const { api, workspace, available, unavailableCause, connection, connectOptions } = ctx
|
|
41
42
|
|
|
42
43
|
async function runPatCheck(): Promise<void> {
|
|
43
44
|
// Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
|
|
@@ -65,11 +66,14 @@ export function createGitHubProbe(
|
|
|
65
66
|
.catch(() => []),
|
|
66
67
|
])
|
|
67
68
|
available.value = true
|
|
69
|
+
unavailableCause.value = null
|
|
68
70
|
connection.value = conn
|
|
69
71
|
connectOptions.value = options
|
|
70
|
-
} catch {
|
|
71
|
-
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// 503 (integration disabled) or any error → hide the UI entry points. The cause is kept, so
|
|
74
|
+
// a surface that states the setup can tell "switched off" from "could not ask".
|
|
72
75
|
available.value = false
|
|
76
|
+
unavailableCause.value = apiErrorStatus(err) === 503 ? 'disabled' : 'failed'
|
|
73
77
|
connection.value = null
|
|
74
78
|
connectOptions.value = []
|
|
75
79
|
}
|
package/app/stores/github.ts
CHANGED
|
@@ -38,6 +38,12 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
38
38
|
|
|
39
39
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
40
40
|
const available = ref<boolean | null>(null)
|
|
41
|
+
/**
|
|
42
|
+
* Why `available` is false: the deployment switched the integration off (a 503), or the probe
|
|
43
|
+
* itself failed. Both hide the GitHub UI, but only the first is a fact about the deployment;
|
|
44
|
+
* the second is an unanswered question. Null while `available` is not false.
|
|
45
|
+
*/
|
|
46
|
+
const unavailableCause = ref<'disabled' | 'failed' | null>(null)
|
|
41
47
|
/** The workspace's VCS connection (App installation or PAT), or null when not connected. */
|
|
42
48
|
const connection = ref<GitHubConnection | null>(null)
|
|
43
49
|
/** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
|
|
@@ -160,6 +166,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
160
166
|
api,
|
|
161
167
|
workspace,
|
|
162
168
|
available,
|
|
169
|
+
unavailableCause,
|
|
163
170
|
connection,
|
|
164
171
|
connectOptions,
|
|
165
172
|
installations,
|
|
@@ -207,6 +214,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
207
214
|
*/
|
|
208
215
|
function reset() {
|
|
209
216
|
available.value = null
|
|
217
|
+
unavailableCause.value = null
|
|
210
218
|
connection.value = null
|
|
211
219
|
connectOptions.value = []
|
|
212
220
|
patCheck.value = null
|
|
@@ -221,6 +229,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
221
229
|
|
|
222
230
|
return {
|
|
223
231
|
available,
|
|
232
|
+
unavailableCause,
|
|
224
233
|
connection,
|
|
225
234
|
connectOptions,
|
|
226
235
|
patCheck,
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -728,6 +728,17 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
728
728
|
// so a later plain open doesn't re-scroll. Null when no section was requested.
|
|
729
729
|
const accountSettingsScrollTarget = ref<string | null>(null)
|
|
730
730
|
|
|
731
|
+
// Setup checklist: every prerequisite for "a task can run and merge" in one list, composed
|
|
732
|
+
// from the probes the setup banners already read (issue #2258).
|
|
733
|
+
const setupChecklistOpen = ref(false)
|
|
734
|
+
function openSetupChecklist() {
|
|
735
|
+
resetHubReturn()
|
|
736
|
+
setupChecklistOpen.value = true
|
|
737
|
+
}
|
|
738
|
+
function closeSetupChecklist() {
|
|
739
|
+
setupChecklistOpen.value = false
|
|
740
|
+
}
|
|
741
|
+
|
|
731
742
|
function openWorkspaceSettings(tab = 'workspace') {
|
|
732
743
|
resetHubReturn()
|
|
733
744
|
workspaceSettingsTab.value = tab
|
|
@@ -765,6 +776,9 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
|
|
|
765
776
|
}
|
|
766
777
|
|
|
767
778
|
return {
|
|
779
|
+
setupChecklistOpen,
|
|
780
|
+
openSetupChecklist,
|
|
781
|
+
closeSetupChecklist,
|
|
768
782
|
workspaceSettingsOpen,
|
|
769
783
|
workspaceSettingsTab,
|
|
770
784
|
accountSettingsOpen,
|