@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,340 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SetupRow, SetupRowCause, SetupRowId, SetupRowStatus } from '~/utils/setupChecklist'
|
|
3
|
+
import type { SetupRemedy } from '~/utils/setupAudience'
|
|
4
|
+
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
5
|
+
import Spinner from '~/components/common/Spinner.vue'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The setup checklist: every prerequisite for "a task can run and merge", its state, and one
|
|
9
|
+
* action per row (issue #2258).
|
|
10
|
+
*
|
|
11
|
+
* The probes and the surfaces each row opens are the ones the setup banners, the AI-provider
|
|
12
|
+
* dialogs and the startup health advisories already use (`useSetupChecklist`), and so is the rule
|
|
13
|
+
* for who may act: a row the caller cannot fix shows who can instead of a button. What it adds is
|
|
14
|
+
* the whole list in one place, with a finish line, instead of one interruption per missing piece.
|
|
15
|
+
* Opening a row's surface closes the checklist, so the two never stack.
|
|
16
|
+
*/
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const ui = useUiStore()
|
|
19
|
+
const { checklist, actionFor, refresh, patSource, patMissing } = useSetupChecklist()
|
|
20
|
+
|
|
21
|
+
const open = computed({
|
|
22
|
+
get: () => ui.setupChecklistOpen,
|
|
23
|
+
set: (v: boolean) => (v ? ui.openSetupChecklist() : ui.closeSetupChecklist()),
|
|
24
|
+
})
|
|
25
|
+
watch(
|
|
26
|
+
() => ui.setupChecklistOpen,
|
|
27
|
+
(isOpen) => {
|
|
28
|
+
if (isOpen) refresh()
|
|
29
|
+
},
|
|
30
|
+
{ immediate: true },
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** Open a surface from the checklist: close the checklist first, so the two never stack. */
|
|
34
|
+
function go(fn: () => void) {
|
|
35
|
+
ui.closeSetupChecklist()
|
|
36
|
+
fn()
|
|
37
|
+
}
|
|
38
|
+
function act(row: SetupRow) {
|
|
39
|
+
const action = actionFor(row)
|
|
40
|
+
if (!action) return
|
|
41
|
+
// Asking a probe again keeps the checklist open: its answer lands here.
|
|
42
|
+
if (row.cause === 'probeFailed') action()
|
|
43
|
+
else go(action)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The rows this caller can act on, with the action to show. */
|
|
47
|
+
function canAct(row: SetupRow): boolean {
|
|
48
|
+
return row.remedy === null && actionFor(row) !== null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The ONE solid button in the view: the first required row that is not done and that this caller
|
|
53
|
+
* can act on. Every other open row takes a lighter variant, so the list reads in order.
|
|
54
|
+
*/
|
|
55
|
+
const primaryRowId = computed<SetupRowId | null>(
|
|
56
|
+
() =>
|
|
57
|
+
checklist.value.rows.find(
|
|
58
|
+
(r) => r.required && (r.status === 'missing' || r.status === 'attention') && canAct(r),
|
|
59
|
+
)?.id ?? null,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const pipelineHealth = usePipelineHealth()
|
|
63
|
+
const riskPolicyHealth = useRiskPolicyHealth()
|
|
64
|
+
const modelPresetHealth = useModelPresetHealth()
|
|
65
|
+
|
|
66
|
+
const STATUS_META: Record<SetupRowStatus, { icon: string; class: string }> = {
|
|
67
|
+
done: { icon: 'i-lucide-circle-check', class: 'text-success' },
|
|
68
|
+
missing: { icon: 'i-lucide-circle', class: 'text-dimmed' },
|
|
69
|
+
attention: { icon: 'i-lucide-circle-alert', class: 'text-warning' },
|
|
70
|
+
checking: { icon: 'i-lucide-loader-circle', class: 'text-dimmed' },
|
|
71
|
+
not_needed: { icon: 'i-lucide-circle-minus', class: 'text-dimmed' },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The action's word per state. Null for the two states that never carry an action
|
|
76
|
+
* (`useSetupChecklist.actionFor`), so no copy ships for a button that cannot render.
|
|
77
|
+
*/
|
|
78
|
+
const ACTION_KEYS: Record<SetupRowStatus, string | null> = {
|
|
79
|
+
done: 'setup.action.change',
|
|
80
|
+
missing: 'setup.action.connect',
|
|
81
|
+
attention: 'setup.action.fix',
|
|
82
|
+
checking: null,
|
|
83
|
+
not_needed: null,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function actionLabel(row: SetupRow): string {
|
|
87
|
+
if (row.cause === 'probeFailed') return t('setup.action.retry')
|
|
88
|
+
const key = ACTION_KEYS[row.status]
|
|
89
|
+
return key ? t(key) : ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whose token a token row is about: the remedy differs, so the row says it, as the banner does. */
|
|
93
|
+
const patSourceKey = computed(() =>
|
|
94
|
+
patSource.value === 'deployment'
|
|
95
|
+
? 'layout.githubPatPermissionsBanner.sourceDeployment'
|
|
96
|
+
: 'layout.githubPatPermissionsBanner.sourceInitiator',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Each row's title and its line for the states that are about the row. An `attention` row's line
|
|
101
|
+
* is its CAUSE (`CAUSE_KEYS`): the title already names the area, and each cause needs its own fix.
|
|
102
|
+
*/
|
|
103
|
+
const ROW_COPY: Record<
|
|
104
|
+
SetupRowId,
|
|
105
|
+
{ title: string } & Record<Exclude<SetupRowStatus, 'attention'>, string>
|
|
106
|
+
> = {
|
|
107
|
+
model: {
|
|
108
|
+
title: 'setup.rows.model.title',
|
|
109
|
+
done: 'setup.rows.model.done',
|
|
110
|
+
missing: 'setup.rows.model.missing',
|
|
111
|
+
checking: 'setup.rows.checking',
|
|
112
|
+
not_needed: 'setup.rows.notNeeded',
|
|
113
|
+
},
|
|
114
|
+
sourceControl: {
|
|
115
|
+
title: 'setup.rows.sourceControl.title',
|
|
116
|
+
done: 'setup.rows.sourceControl.done',
|
|
117
|
+
missing: 'setup.rows.sourceControl.missing',
|
|
118
|
+
checking: 'setup.rows.checking',
|
|
119
|
+
not_needed: 'setup.rows.notNeeded',
|
|
120
|
+
},
|
|
121
|
+
agentExecutor: {
|
|
122
|
+
title: 'setup.rows.agentExecutor.title',
|
|
123
|
+
done: 'setup.rows.agentExecutor.done',
|
|
124
|
+
missing: 'setup.rows.agentExecutor.missing',
|
|
125
|
+
checking: 'setup.rows.checking',
|
|
126
|
+
not_needed: 'setup.rows.agentExecutor.notNeeded',
|
|
127
|
+
},
|
|
128
|
+
ephemeralEnvironments: {
|
|
129
|
+
title: 'setup.rows.ephemeralEnvironments.title',
|
|
130
|
+
done: 'setup.rows.ephemeralEnvironments.done',
|
|
131
|
+
missing: 'setup.rows.ephemeralEnvironments.missing',
|
|
132
|
+
checking: 'setup.rows.checking',
|
|
133
|
+
not_needed: 'setup.rows.notNeeded',
|
|
134
|
+
},
|
|
135
|
+
binaryStorage: {
|
|
136
|
+
title: 'setup.rows.binaryStorage.title',
|
|
137
|
+
done: 'setup.rows.binaryStorage.done',
|
|
138
|
+
missing: 'setup.rows.binaryStorage.missing',
|
|
139
|
+
checking: 'setup.rows.checking',
|
|
140
|
+
not_needed: 'setup.rows.notNeeded',
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
const CAUSE_KEYS: Record<SetupRowCause, string> = {
|
|
144
|
+
unreachable: 'setup.causes.unreachable',
|
|
145
|
+
incomplete: 'setup.causes.incomplete',
|
|
146
|
+
rejected: 'setup.causes.rejected',
|
|
147
|
+
underscoped: 'setup.causes.underscoped',
|
|
148
|
+
presetBroken: 'setup.causes.presetBroken',
|
|
149
|
+
probeFailed: 'setup.causes.probeFailed',
|
|
150
|
+
notReported: 'setup.causes.notReported',
|
|
151
|
+
}
|
|
152
|
+
function rowLine(row: SetupRow): string {
|
|
153
|
+
return row.status === 'attention' ? t(CAUSE_KEYS[row.cause]) : t(ROW_COPY[row.id][row.status])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The same "who can fix this" lines the infrastructure notice shows a caller who cannot act.
|
|
157
|
+
const REMEDY_KEYS: Record<SetupRemedy, string> = {
|
|
158
|
+
workspace_admin: 'layout.infraSetupNotice.owner.workspace_admin',
|
|
159
|
+
account_admin: 'layout.infraSetupNotice.owner.account_admin',
|
|
160
|
+
operator: 'layout.infraSetupNotice.owner.operator',
|
|
161
|
+
full_surface: 'layout.infraSetupNotice.fullSurface',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The secondary list: advisories that used to open as startup dialogs, each count on its own so
|
|
166
|
+
* no sentence has to agree with a number. Only rows with work.
|
|
167
|
+
*/
|
|
168
|
+
const also = computed(() =>
|
|
169
|
+
[
|
|
170
|
+
{
|
|
171
|
+
id: 'pipelines',
|
|
172
|
+
labelKey: 'setup.also.pipelines',
|
|
173
|
+
counts: [
|
|
174
|
+
{
|
|
175
|
+
key: 'setup.also.counts.repair',
|
|
176
|
+
n:
|
|
177
|
+
pipelineHealth.health.value.filter((h) => h.invalid).length +
|
|
178
|
+
pipelineHealth.retired.value.length,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
key: 'setup.also.counts.outdated',
|
|
182
|
+
n: pipelineHealth.health.value.filter((h) => !h.invalid && h.outdated).length,
|
|
183
|
+
},
|
|
184
|
+
{ key: 'setup.also.counts.new', n: pipelineHealth.newPipelines.value.length },
|
|
185
|
+
],
|
|
186
|
+
open: () => go(() => ui.openPipelineHealth()),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'riskPolicies',
|
|
190
|
+
labelKey: 'setup.also.riskPolicies',
|
|
191
|
+
counts: [
|
|
192
|
+
{ key: 'setup.also.counts.outdated', n: riskPolicyHealth.outdated.value.length },
|
|
193
|
+
{ key: 'setup.also.counts.new', n: riskPolicyHealth.newPresets.value.length },
|
|
194
|
+
],
|
|
195
|
+
open: () => go(() => ui.openRiskPolicyHealth()),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'modelPresets',
|
|
199
|
+
labelKey: 'setup.also.modelPresets',
|
|
200
|
+
counts: [
|
|
201
|
+
{ key: 'setup.also.counts.outdated', n: modelPresetHealth.outdated.value.length },
|
|
202
|
+
{ key: 'setup.also.counts.new', n: modelPresetHealth.newPresets.value.length },
|
|
203
|
+
],
|
|
204
|
+
open: () => go(() => ui.openModelPresetHealth()),
|
|
205
|
+
},
|
|
206
|
+
]
|
|
207
|
+
.map((row) => ({ ...row, counts: row.counts.filter((c) => c.n > 0) }))
|
|
208
|
+
.filter((row) => row.counts.length > 0),
|
|
209
|
+
)
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<template>
|
|
213
|
+
<UModal v-model:open="open" :title="t('setup.title')" :ui="{ content: 'sm:max-w-3xl' }">
|
|
214
|
+
<template #title>
|
|
215
|
+
<span class="flex items-baseline gap-3">
|
|
216
|
+
{{ t('setup.title') }}
|
|
217
|
+
<!-- While a probe has not answered, the count is not one yet, so the badge says so. -->
|
|
218
|
+
<UBadge color="neutral" variant="subtle" data-testid="setup-progress">
|
|
219
|
+
{{
|
|
220
|
+
checklist.checking
|
|
221
|
+
? t('setup.rows.checking')
|
|
222
|
+
: t('setup.progress', { done: checklist.done, total: checklist.total })
|
|
223
|
+
}}
|
|
224
|
+
</UBadge>
|
|
225
|
+
</span>
|
|
226
|
+
</template>
|
|
227
|
+
<template #body>
|
|
228
|
+
<div data-testid="setup-checklist">
|
|
229
|
+
<p class="text-sm text-muted">{{ t('setup.intro') }}</p>
|
|
230
|
+
|
|
231
|
+
<ul class="mt-6 space-y-3">
|
|
232
|
+
<li
|
|
233
|
+
v-for="row in checklist.rows"
|
|
234
|
+
:key="row.id"
|
|
235
|
+
class="flex items-center gap-3 rounded-lg border border-muted bg-default p-4"
|
|
236
|
+
data-testid="setup-row"
|
|
237
|
+
:data-row="row.id"
|
|
238
|
+
:data-status="row.status"
|
|
239
|
+
:data-cause="row.cause ?? undefined"
|
|
240
|
+
:data-remedy="row.remedy ?? undefined"
|
|
241
|
+
>
|
|
242
|
+
<Spinner
|
|
243
|
+
:name="STATUS_META[row.status].icon"
|
|
244
|
+
:spinning="row.status === 'checking'"
|
|
245
|
+
class="h-5 w-5 shrink-0"
|
|
246
|
+
:class="STATUS_META[row.status].class"
|
|
247
|
+
/>
|
|
248
|
+
<div class="min-w-0 flex-1">
|
|
249
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
250
|
+
<span class="font-medium text-highlighted">{{ t(ROW_COPY[row.id].title) }}</span>
|
|
251
|
+
<UBadge v-if="row.status !== 'done'" size="sm" color="neutral" variant="outline">
|
|
252
|
+
{{ row.required ? t('setup.required') : t('setup.optional') }}
|
|
253
|
+
</UBadge>
|
|
254
|
+
</div>
|
|
255
|
+
<p class="mt-0.5 text-xs text-muted">{{ rowLine(row) }}</p>
|
|
256
|
+
<!-- The token rows carry the banners' second half: whose token it is, and, for a
|
|
257
|
+
missing local token, where the new one goes. -->
|
|
258
|
+
<p
|
|
259
|
+
v-if="row.cause === 'rejected' || row.cause === 'underscoped'"
|
|
260
|
+
class="mt-0.5 text-xs text-dimmed"
|
|
261
|
+
>
|
|
262
|
+
{{ t(patSourceKey) }}
|
|
263
|
+
</p>
|
|
264
|
+
<p
|
|
265
|
+
v-else-if="row.id === 'sourceControl' && row.status === 'missing' && patMissing"
|
|
266
|
+
class="mt-0.5 text-xs text-dimmed"
|
|
267
|
+
>
|
|
268
|
+
<i18n-t keypath="layout.githubPatBanner.thenSet" tag="span" scope="global">
|
|
269
|
+
<template #envVar>
|
|
270
|
+
<code class="font-mono">GITHUB_PAT</code>
|
|
271
|
+
</template>
|
|
272
|
+
</i18n-t>
|
|
273
|
+
</p>
|
|
274
|
+
<!-- A gap this caller cannot close: who can, in place of a button their save would
|
|
275
|
+
be refused on. A done row needs neither. -->
|
|
276
|
+
<p
|
|
277
|
+
v-if="row.remedy && row.status !== 'done' && row.status !== 'not_needed'"
|
|
278
|
+
class="mt-0.5 text-xs text-dimmed"
|
|
279
|
+
data-testid="setup-row-remedy"
|
|
280
|
+
>
|
|
281
|
+
{{ t(REMEDY_KEYS[row.remedy]) }}
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
<UButton
|
|
285
|
+
v-if="canAct(row)"
|
|
286
|
+
size="sm"
|
|
287
|
+
:color="row.status === 'done' ? 'neutral' : 'primary'"
|
|
288
|
+
:variant="
|
|
289
|
+
row.id === primaryRowId ? 'solid' : row.status === 'done' ? 'outline' : 'soft'
|
|
290
|
+
"
|
|
291
|
+
data-testid="setup-row-action"
|
|
292
|
+
@click="act(row)"
|
|
293
|
+
>
|
|
294
|
+
{{ actionLabel(row) }}
|
|
295
|
+
</UButton>
|
|
296
|
+
</li>
|
|
297
|
+
</ul>
|
|
298
|
+
|
|
299
|
+
<USeparator class="my-6" />
|
|
300
|
+
<SectionLabel as="h2" class="mb-3">{{ t('setup.also.title') }}</SectionLabel>
|
|
301
|
+
<ul class="space-y-2 text-sm">
|
|
302
|
+
<li v-for="row in also" :key="row.id" class="flex flex-wrap items-center gap-2">
|
|
303
|
+
<span class="text-muted">{{ t(row.labelKey) }}</span>
|
|
304
|
+
<UBadge v-for="c in row.counts" :key="c.key" size="sm" color="neutral" variant="subtle">
|
|
305
|
+
{{ t(c.key, { count: c.n }) }}
|
|
306
|
+
</UBadge>
|
|
307
|
+
<UButton
|
|
308
|
+
class="ms-auto"
|
|
309
|
+
size="xs"
|
|
310
|
+
color="neutral"
|
|
311
|
+
variant="outline"
|
|
312
|
+
@click="row.open()"
|
|
313
|
+
>
|
|
314
|
+
{{ t('setup.action.review') }}
|
|
315
|
+
</UButton>
|
|
316
|
+
</li>
|
|
317
|
+
<li class="flex flex-wrap items-center gap-2 text-muted">
|
|
318
|
+
<span class="flex-1">{{ t('setup.also.settings') }}</span>
|
|
319
|
+
<UButton
|
|
320
|
+
size="xs"
|
|
321
|
+
color="neutral"
|
|
322
|
+
variant="ghost"
|
|
323
|
+
@click="go(() => ui.openWorkspaceSettings())"
|
|
324
|
+
>
|
|
325
|
+
{{ t('setup.also.workspaceSettings') }}
|
|
326
|
+
</UButton>
|
|
327
|
+
<UButton
|
|
328
|
+
size="xs"
|
|
329
|
+
color="neutral"
|
|
330
|
+
variant="ghost"
|
|
331
|
+
@click="go(() => ui.openIntegrations())"
|
|
332
|
+
>
|
|
333
|
+
{{ t('setup.also.integrations') }}
|
|
334
|
+
</UButton>
|
|
335
|
+
</li>
|
|
336
|
+
</ul>
|
|
337
|
+
</div>
|
|
338
|
+
</template>
|
|
339
|
+
</UModal>
|
|
340
|
+
</template>
|
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
import { computed, type Ref } from 'vue'
|
|
2
|
-
import type { Block } from '~/types/domain'
|
|
3
2
|
import { createLaneMemo } from '~/utils/laneIdentity'
|
|
4
3
|
import {
|
|
5
4
|
groupLaneTasks,
|
|
6
|
-
runActivityAt,
|
|
7
|
-
runWaitingSince,
|
|
8
5
|
sortLaneTasks,
|
|
9
6
|
type LaneTaskEntry,
|
|
10
7
|
type RenderedLane,
|
|
11
8
|
} from '~/utils/laneSort'
|
|
12
9
|
import {
|
|
13
|
-
classifyTask,
|
|
14
10
|
selectDoneLaneTasks,
|
|
15
11
|
TASK_LANES,
|
|
16
12
|
type DoneLaneSelection,
|
|
17
13
|
type TaskLane,
|
|
18
14
|
} from '~/utils/swimlanes'
|
|
19
15
|
|
|
20
|
-
/** A lane entry plus the lane it was classified into. */
|
|
21
|
-
interface ClassifiedEntry {
|
|
22
|
-
readonly entry: LaneTaskEntry
|
|
23
|
-
readonly lane: TaskLane
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
/**
|
|
27
17
|
* Assemble a service frame's tasks into swimlanes.
|
|
28
18
|
*
|
|
@@ -34,12 +24,10 @@ interface ClassifiedEntry {
|
|
|
34
24
|
*/
|
|
35
25
|
export function useFrameLanes(frameId: Ref<string>) {
|
|
36
26
|
const board = useBoardStore()
|
|
37
|
-
const execution = useExecutionStore()
|
|
38
|
-
const agentRuns = useAgentRunsStore()
|
|
39
|
-
const notifications = useNotificationsStore()
|
|
40
27
|
const settings = useWorkspaceSettingsStore()
|
|
41
28
|
const laneView = useLaneViewStore()
|
|
42
|
-
|
|
29
|
+
// The workspace-wide classification: every frame and the queue read one verdict per task.
|
|
30
|
+
const queue = useWorkspaceQueueStore()
|
|
43
31
|
|
|
44
32
|
/** Tasks directly in the frame plus those inside its modules: a module renders no box now. */
|
|
45
33
|
const tasks = computed(() => board.allTasksUnder(frameId.value))
|
|
@@ -49,78 +37,14 @@ export function useFrameLanes(frameId: Ref<string>) {
|
|
|
49
37
|
() => new Map(board.modulesOf(frameId.value).map((m) => [m.title, m.id])),
|
|
50
38
|
)
|
|
51
39
|
|
|
52
|
-
/**
|
|
53
|
-
* The module a task belongs to: the module BLOCK's title when it already lives in one, else
|
|
54
|
-
* the module it DECLARES. The engine only materialises the block on merge
|
|
55
|
-
* (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
|
|
56
|
-
* "no module" while its own card names one.
|
|
57
|
-
*/
|
|
58
|
-
function moduleNameOf(task: Block): string | null {
|
|
59
|
-
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
60
|
-
if (parent?.level === 'module') return parent.title
|
|
61
|
-
return task.moduleName?.trim() || null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* One task's lane, reason and rendered entry.
|
|
66
|
-
*
|
|
67
|
-
* Every cross-block lookup here is a Map read off an index a STORE maintains, never a reduction
|
|
68
|
-
* of its own: this composable runs ONE INSTANCE PER MOUNTED FRAME, so deriving a workspace-wide
|
|
69
|
-
* fact here makes a board with n frames pay for it n times on every change. The review-debt map
|
|
70
|
-
* (`notifications.reviewDebtByBlock`) is the worked example: a reduction over the whole
|
|
71
|
-
* workspace's open notifications, and it belongs on the store that owns its input. The rule for
|
|
72
|
-
* anything else this assembly needs: a per-FRAME derivation belongs here, a workspace-wide one
|
|
73
|
-
* belongs on that store.
|
|
74
|
-
*/
|
|
75
|
-
function classify(task: Block, order: number): ClassifiedEntry {
|
|
76
|
-
const run = execution.getByBlock(task.id) ?? null
|
|
77
|
-
const decisions = execution.decisionsByBlock.get(task.id) ?? []
|
|
78
|
-
const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
|
|
79
|
-
// The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
|
|
80
|
-
// holds a pending approval while the driver folds answers in, and nobody is waiting on it.
|
|
81
|
-
const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
|
|
82
|
-
|
|
83
|
-
const { lane, reason } = classifyTask({
|
|
84
|
-
status: task.status,
|
|
85
|
-
// Read from the coarse per-block summary, which also covers a bootstrap run.
|
|
86
|
-
runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
|
|
87
|
-
run,
|
|
88
|
-
// A park is background exactly when everything asking was suppressed AND nothing else
|
|
89
|
-
// asks. With no approvals at all it is NOT background: it is a park on a surface this
|
|
90
|
-
// layer cannot name, which `classifyTask` reports as `parked` rather than as work.
|
|
91
|
-
parkIsBackground:
|
|
92
|
-
decisions.length === 0 &&
|
|
93
|
-
humanApprovals.length === 0 &&
|
|
94
|
-
allApprovals.length > humanApprovals.length,
|
|
95
|
-
pendingDecision: decisions.length > 0,
|
|
96
|
-
pendingApproval: humanApprovals.length > 0,
|
|
97
|
-
hasUnmetDeps: board.unmetDeps(task.id).length > 0,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
lane,
|
|
102
|
-
entry: {
|
|
103
|
-
task,
|
|
104
|
-
reason,
|
|
105
|
-
order,
|
|
106
|
-
activityAt: runActivityAt(run),
|
|
107
|
-
waitingSince: runWaitingSince(run, notifications.reviewDebtByBlock.get(task.id) ?? null),
|
|
108
|
-
moduleName: moduleNameOf(task),
|
|
109
|
-
initiativeName: task.initiativeId
|
|
110
|
-
? (board.getBlock(task.initiativeId)?.title ?? null)
|
|
111
|
-
: null,
|
|
112
|
-
epicName: board.epicOf(task)?.title ?? null,
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
40
|
/** Every task bucketed by lane, in board order, before sorting. */
|
|
118
41
|
const byLane = computed(() => {
|
|
119
42
|
const buckets = new Map<TaskLane, LaneTaskEntry[]>(TASK_LANES.map((lane) => [lane, []]))
|
|
120
|
-
tasks.value
|
|
121
|
-
|
|
43
|
+
for (const task of tasks.value) {
|
|
44
|
+
// The store walks every frame's tasks, this one's included, so each has a verdict.
|
|
45
|
+
const { lane, entry } = queue.classified.get(task.id)!
|
|
122
46
|
buckets.get(lane)!.push(entry)
|
|
123
|
-
}
|
|
47
|
+
}
|
|
124
48
|
return buckets
|
|
125
49
|
})
|
|
126
50
|
|
|
@@ -55,6 +55,7 @@ export function useNavContributions() {
|
|
|
55
55
|
foundationalServices: () => ui.openFoundationalServices(),
|
|
56
56
|
mergeThresholds: () => ui.openWorkspaceSettings('merge'),
|
|
57
57
|
workspaceSettings: () => ui.openWorkspaceSettings(),
|
|
58
|
+
setupChecklist: () => ui.openSetupChecklist(),
|
|
58
59
|
modelConfiguration: () => ui.openModelConfig(),
|
|
59
60
|
serviceFragmentDefaults: () => ui.openWorkspaceSettings('fragments'),
|
|
60
61
|
localModels: () => ui.openLocalModels(),
|
|
@@ -72,6 +73,8 @@ export function useNavContributions() {
|
|
|
72
73
|
// The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
|
|
73
74
|
// prompt is the one surface that states what each role gives you before you pick it.
|
|
74
75
|
chooseRole: () => useUiRoleStore().openPrompt(),
|
|
76
|
+
queueView: () => useWorkViewStore().show('queue'),
|
|
77
|
+
boardView: () => useWorkViewStore().show('board'),
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { githubPatCheckNeedsAttention } from '@cat-factory/contracts'
|
|
3
|
+
import { deriveSetupChecklist, type SetupRow } from '~/utils/setupChecklist'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The store-facing half of `utils/setupChecklist.ts`: resolve every probe the checklist reads,
|
|
7
|
+
* and the existing surface each row's action opens.
|
|
8
|
+
*
|
|
9
|
+
* Every action goes to the surface the matching banner or dialog opens for the same state, so
|
|
10
|
+
* the checklist is a new index over the setup the product has, never a second copy of it.
|
|
11
|
+
*/
|
|
12
|
+
export function useSetupChecklist() {
|
|
13
|
+
const ui = useUiStore()
|
|
14
|
+
const github = useGitHubStore()
|
|
15
|
+
const models = useModelsStore()
|
|
16
|
+
const workspace = useWorkspaceStore()
|
|
17
|
+
const providerConnections = useProviderConnectionsStore()
|
|
18
|
+
const ai = useAiReadiness()
|
|
19
|
+
const { actor } = useSetupActor()
|
|
20
|
+
// Where each gap is fixed: the map the setup banners open through too.
|
|
21
|
+
const remedies = useSetupRemedies()
|
|
22
|
+
/** Local mode with no token: the row says where the new one goes, as the banner does. */
|
|
23
|
+
const patMissing = computed(() => !!remedies.patSetupUrl.value)
|
|
24
|
+
|
|
25
|
+
// The catalog is loaded on board open, and a failed load is dropped there on purpose (it stays
|
|
26
|
+
// retryable, see `models.prefetchForBoard`). The checklist is where that failure is stated, so
|
|
27
|
+
// it asks for the catalog itself and keeps the outcome.
|
|
28
|
+
const modelLoadFailed = ref(false)
|
|
29
|
+
async function loadModels() {
|
|
30
|
+
const id = workspace.workspaceId
|
|
31
|
+
if (!id) return
|
|
32
|
+
modelLoadFailed.value = false
|
|
33
|
+
try {
|
|
34
|
+
await models.ensureLoaded(id)
|
|
35
|
+
} catch {
|
|
36
|
+
modelLoadFailed.value = true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ask every probe the checklist reads, so a row does not wait on a banner that happens to be
|
|
42
|
+
* mounted. Each is single-flighted or load-once in its store, so this costs nothing when the
|
|
43
|
+
* board already asked.
|
|
44
|
+
*/
|
|
45
|
+
function refresh() {
|
|
46
|
+
void loadModels()
|
|
47
|
+
void providerConnections.ensureLoaded()
|
|
48
|
+
void github.ensureProbed()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const patCheck = computed(() => github.patCheck)
|
|
52
|
+
const patProblem = computed<'rejected' | 'underscoped' | null>(() => {
|
|
53
|
+
const check = patCheck.value
|
|
54
|
+
if (!check || !githubPatCheckNeedsAttention(check)) return null
|
|
55
|
+
return check.state === 'token_rejected' ? 'rejected' : 'underscoped'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const checklist = computed(() =>
|
|
59
|
+
deriveSetupChecklist({
|
|
60
|
+
model: {
|
|
61
|
+
loaded: ai.ready.value,
|
|
62
|
+
loadFailed: modelLoadFailed.value,
|
|
63
|
+
usable: ai.hasUsableModel.value,
|
|
64
|
+
presetBroken: ai.defaultPresetBroken.value,
|
|
65
|
+
},
|
|
66
|
+
sourceControl: {
|
|
67
|
+
available: github.available,
|
|
68
|
+
unavailableCause: github.unavailableCause,
|
|
69
|
+
connected: github.connected,
|
|
70
|
+
patMissing: patMissing.value,
|
|
71
|
+
patProblem: patProblem.value,
|
|
72
|
+
},
|
|
73
|
+
infra: workspace.infraSetup ?? null,
|
|
74
|
+
workspaceLoaded: workspace.ready,
|
|
75
|
+
infraOwners: workspace.infraSetupOwners ?? null,
|
|
76
|
+
providers: {
|
|
77
|
+
loaded: providerConnections.loaded,
|
|
78
|
+
needingConfig: providerConnections.needingConfig,
|
|
79
|
+
},
|
|
80
|
+
actor: actor.value,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where a row's action goes, by row AND state, through the remedies the matching banner uses.
|
|
86
|
+
* Null where there is nothing to open: the deployment does not use the area, or does not report
|
|
87
|
+
* it.
|
|
88
|
+
*/
|
|
89
|
+
function actionFor(row: SetupRow): (() => void) | null {
|
|
90
|
+
if (row.status === 'not_needed' || row.status === 'checking') return null
|
|
91
|
+
if (row.cause === 'notReported') return null
|
|
92
|
+
switch (row.id) {
|
|
93
|
+
case 'model':
|
|
94
|
+
if (row.cause === 'probeFailed') return () => void loadModels()
|
|
95
|
+
if (row.status === 'missing') return remedies.openModelSetup
|
|
96
|
+
if (row.cause === 'presetBroken') return remedies.openPresetMismatch
|
|
97
|
+
return () => ui.openModelProviders()
|
|
98
|
+
case 'sourceControl': {
|
|
99
|
+
const setupUrl = remedies.patSetupUrl.value
|
|
100
|
+
if (setupUrl) return () => window.open(setupUrl, '_blank', 'noopener')
|
|
101
|
+
if (row.cause === 'rejected' || row.cause === 'underscoped') {
|
|
102
|
+
const remint = remedies.patRemintUrl.value
|
|
103
|
+
return () => window.open(remint, '_blank', 'noopener')
|
|
104
|
+
}
|
|
105
|
+
if (row.cause === 'probeFailed') return () => void github.probe()
|
|
106
|
+
return () => ui.openGitHub()
|
|
107
|
+
}
|
|
108
|
+
case 'agentExecutor':
|
|
109
|
+
case 'ephemeralEnvironments':
|
|
110
|
+
case 'binaryStorage': {
|
|
111
|
+
const area = row.id
|
|
112
|
+
return () => remedies.openInfraArea(area)
|
|
113
|
+
}
|
|
114
|
+
default: {
|
|
115
|
+
const unreachable: never = row.id
|
|
116
|
+
return unreachable
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { checklist, actionFor, refresh, patSource: remedies.patSource, patMissing }
|
|
122
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { githubPatCheckSource } from '@cat-factory/contracts'
|
|
3
|
+
import type { GitHubPatKind, InfraSetupArea } from '~/types/domain'
|
|
4
|
+
import { githubPatRemintUrl } from '~/utils/vcs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where each setup gap is fixed: the ONE map the setup banners and the setup checklist both open
|
|
8
|
+
* through, so "the row opens what its banner opens" holds because there is one place that says
|
|
9
|
+
* it, never two copies kept in step by hand.
|
|
10
|
+
*
|
|
11
|
+
* Keyed by `InfraSetupArea` for the infrastructure, the vocabulary the server reports in, plus the
|
|
12
|
+
* model and source-control remedies, which have no area of their own.
|
|
13
|
+
*/
|
|
14
|
+
export function useSetupRemedies() {
|
|
15
|
+
const ui = useUiStore()
|
|
16
|
+
const auth = useAuthStore()
|
|
17
|
+
const github = useGitHubStore()
|
|
18
|
+
|
|
19
|
+
const INFRA: Record<InfraSetupArea, () => void> = {
|
|
20
|
+
agentExecutor: () => ui.openProviderConnection('runner-pool'),
|
|
21
|
+
ephemeralEnvironments: () => ui.openProviderConnection('environment'),
|
|
22
|
+
binaryStorage: () => ui.openContentStorageSettings(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Open the screen that configures an infrastructure area. */
|
|
26
|
+
function openInfraArea(area: InfraSetupArea) {
|
|
27
|
+
INFRA[area]()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** No usable model: the AI onboarding dialog. */
|
|
31
|
+
function openModelSetup() {
|
|
32
|
+
ui.openAiProviderSetup()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Usable models, but the default preset names one that is not: the preset-mismatch dialog. */
|
|
36
|
+
function openPresetMismatch() {
|
|
37
|
+
ui.openAiPresetMismatch()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Local mode with no token: GitHub's token page, scopes preselected. Null outside that case. */
|
|
41
|
+
const patSetupUrl = computed(() => auth.localMode?.githubPatSetupUrl ?? null)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The token page for a token GitHub rejected or under-scoped. A rejected token was never
|
|
45
|
+
* classified, so it falls back to `unknown`, which lands on the classic form, the only one
|
|
46
|
+
* GitHub lets us pre-fill.
|
|
47
|
+
*/
|
|
48
|
+
const patReport = computed(() =>
|
|
49
|
+
github.patCheck?.state === 'checked' ? github.patCheck.report : undefined,
|
|
50
|
+
)
|
|
51
|
+
/** The token's kind, which picks the form. `unknown` for a token GitHub rejected outright. */
|
|
52
|
+
const patKind = computed<GitHubPatKind>(() => patReport.value?.kind ?? 'unknown')
|
|
53
|
+
const patRemintUrl = computed(() =>
|
|
54
|
+
githubPatRemintUrl(patKind.value, patReport.value?.webUrl ?? github.connection?.webUrl),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whose token that is: the deployment's (replaced by whoever runs it) or the person's own
|
|
59
|
+
* (replaced in their settings). Read off the check, which carries it in every state it reports.
|
|
60
|
+
*/
|
|
61
|
+
const patSource = computed(() => (github.patCheck ? githubPatCheckSource(github.patCheck) : null))
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
openInfraArea,
|
|
65
|
+
openModelSetup,
|
|
66
|
+
openPresetMismatch,
|
|
67
|
+
patSetupUrl,
|
|
68
|
+
patKind,
|
|
69
|
+
patRemintUrl,
|
|
70
|
+
patSource,
|
|
71
|
+
}
|
|
72
|
+
}
|