@cat-factory/app 0.311.3 → 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 +27 -0
- package/app/components/board/nodes/TaskCard.vue +45 -212
- package/app/components/layout/BoardToolbar.vue +24 -26
- package/app/components/layout/BoardTopOverlays.vue +8 -2
- 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/composables/useFrameLanes.ts +6 -82
- package/app/composables/useNavContributions.ts +2 -0
- 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 +39 -6
- 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/workView.spec.ts +16 -0
- package/app/utils/workView.ts +25 -0
- package/i18n/locales/de.json +61 -2
- package/i18n/locales/en.json +61 -2
- package/i18n/locales/es.json +61 -2
- package/i18n/locales/fr.json +61 -2
- package/i18n/locales/he.json +61 -2
- package/i18n/locales/it.json +61 -2
- package/i18n/locales/ja.json +61 -2
- package/i18n/locales/pl.json +61 -2
- package/i18n/locales/tr.json +61 -2
- package/i18n/locales/uk.json +61 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
17
17
|
- [Layout](#layout)
|
|
18
18
|
- [Nuxt UI: agent tooling and overrides](#nuxt-ui-agent-tooling-and-where-the-spa-overrides-the-skill)
|
|
19
19
|
- [Task swimlanes](#task-swimlanes)
|
|
20
|
+
- [Queue view](#queue-view)
|
|
20
21
|
- [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
|
|
21
22
|
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
22
23
|
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
|
|
@@ -624,6 +625,32 @@ does not visibly jump when the response lands. "No module" is the EMPTY STRING o
|
|
|
624
625
|
every other clearable field spells a clear; `undefined` is dropped by `JSON.stringify` and reaches
|
|
625
626
|
the server as an empty patch.
|
|
626
627
|
|
|
628
|
+
## Queue view
|
|
629
|
+
|
|
630
|
+
A board has two views (issue [#2258](https://github.com/kibertoad/cat-factory/issues/2258)): the
|
|
631
|
+
**queue**, which lists every task across every service in the column that says what it needs, and
|
|
632
|
+
the **board**, the canvas where the work is planned. They are two views of one board, not two
|
|
633
|
+
products. The sidebar's Views switcher (`components/layout/WorkViewSwitcher.vue`) moves between
|
|
634
|
+
them, and so do the palette entries and a `?view=queue` / `?view=board` link. The initiative it
|
|
635
|
+
starts, with the slices after it: [`delivery-surface.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/delivery-surface.md).
|
|
636
|
+
|
|
637
|
+
- **Which view opens is the person's own choice**: the view they used last in this browser
|
|
638
|
+
(`stores/workView.ts`, a cookie persisted like the interface tier, so two accounts on one browser
|
|
639
|
+
share it). A person who has not chosen
|
|
640
|
+
lands on the queue, because it answers the first question a person opens the app with. Both
|
|
641
|
+
views exist in every tier and every role: neither is an override of the other.
|
|
642
|
+
- **The queue states the canvas's verdict, regrouped.** `useTaskLaneClassifier` is the one
|
|
643
|
+
classifier the frame swimlanes and the queue share; `utils/queueSections.ts` only regroups its
|
|
644
|
+
output (a waiting pull request gets its own column), and no task crosses a lane. The
|
|
645
|
+
workspace-wide walk lives on one store (`stores/workspaceQueue.ts`), per the swimlane rule on
|
|
646
|
+
workspace-wide derivations.
|
|
647
|
+
- **A card's actions are the board card's** (`useTaskActions`), so Start, Resolve and Merge
|
|
648
|
+
cannot drift between the two views.
|
|
649
|
+
- **The queue sits over the canvas**, which stays mounted, so switching back keeps its camera and
|
|
650
|
+
selection. The advisory banners render in the page flow above the queue's columns
|
|
651
|
+
(`BoardTopOverlays` with `in-flow`), and the canvas toolbar does not, being the canvas's own
|
|
652
|
+
chrome. The canvas's decision counter opens the queue view.
|
|
653
|
+
|
|
627
654
|
## Roles (engineer / product manager / designer)
|
|
628
655
|
|
|
629
656
|
The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { defaultBuildPipelineId } from '@cat-factory/contracts'
|
|
3
2
|
import type { Block } from '~/types/domain'
|
|
4
3
|
import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
|
|
5
|
-
import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
|
|
6
4
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
7
5
|
import TaskPipelineMini from './TaskPipelineMini.vue'
|
|
8
6
|
import IconButton from '~/components/common/IconButton.vue'
|
|
@@ -10,16 +8,28 @@ import IconButton from '~/components/common/IconButton.vue'
|
|
|
10
8
|
const props = defineProps<{ taskId: string }>()
|
|
11
9
|
|
|
12
10
|
const board = useBoardStore()
|
|
13
|
-
const execution = useExecutionStore()
|
|
14
|
-
const pipelines = usePipelinesStore()
|
|
15
11
|
const ui = useUiStore()
|
|
16
12
|
const agentRuns = useAgentRunsStore()
|
|
17
|
-
const reviews = useReviewStage()
|
|
18
|
-
const toast = useToast()
|
|
19
13
|
const { t } = useI18n()
|
|
20
|
-
const { confirm } = useConfirm()
|
|
21
14
|
|
|
22
|
-
|
|
15
|
+
// What the card offers a human to do, shared with the queue's card (see `useTaskActions`).
|
|
16
|
+
const {
|
|
17
|
+
task,
|
|
18
|
+
sandboxed,
|
|
19
|
+
unmet,
|
|
20
|
+
runnable,
|
|
21
|
+
canExecute,
|
|
22
|
+
executeDeniedTitle,
|
|
23
|
+
defaultPipeline,
|
|
24
|
+
outcomeReadable,
|
|
25
|
+
openOutcome,
|
|
26
|
+
starting,
|
|
27
|
+
start: run,
|
|
28
|
+
merge,
|
|
29
|
+
reviewStage,
|
|
30
|
+
reviewStageLabel,
|
|
31
|
+
attention,
|
|
32
|
+
} = useTaskActions(() => props.taskId)
|
|
23
33
|
const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
|
|
24
34
|
|
|
25
35
|
// A type badge for any NON-default task type — built-in (bug/document/spike/review/ralph) or a
|
|
@@ -39,13 +49,6 @@ const typeBadge = computed(() => {
|
|
|
39
49
|
})
|
|
40
50
|
const selected = computed(() => ui.selectedBlockId === props.taskId)
|
|
41
51
|
|
|
42
|
-
// This card's Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
|
|
43
|
-
// request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
|
|
44
|
-
// their role means this button opens a pull request and merges nothing, and a card that said so
|
|
45
|
-
// only after the fact would leave that to be discovered from a run that stops at the merge.
|
|
46
|
-
const { forcedFor } = useDryRunPolicy()
|
|
47
|
-
const sandboxed = computed(() => forcedFor(props.taskId))
|
|
48
|
-
|
|
49
52
|
// Drag-to-connect: dragging from this card's handle onto another task makes THAT task
|
|
50
53
|
// depend on this one (this is the prerequisite). The composable tracks the gesture.
|
|
51
54
|
const { start: startConnect } = useDependencyConnect()
|
|
@@ -56,84 +59,28 @@ const deps = computed(() =>
|
|
|
56
59
|
)
|
|
57
60
|
const uiMode = useUiModeStore()
|
|
58
61
|
|
|
59
|
-
/** Deps that haven't merged yet — these block this task from running. */
|
|
60
|
-
const unmet = computed(() => board.unmetDeps(props.taskId))
|
|
61
|
-
const runnable = computed(() => board.isRunnable(props.taskId))
|
|
62
|
-
|
|
63
62
|
/** Label a dependency, noting its frame when it lives in another one. */
|
|
64
63
|
const { depLabel: labelDep } = useDepLabels()
|
|
65
64
|
const depLabel = (dep: Block) => labelDep(dep, task.value?.parentId)
|
|
66
65
|
|
|
67
|
-
/**
|
|
68
|
-
* The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
|
|
69
|
-
* INTERFACE MODE defaults to (`defaultBuildPipelineId` — the fixed Standard build in basic mode,
|
|
70
|
-
* the Adaptive one in advanced). The workspace's positional first pipeline remains the last
|
|
71
|
-
* resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
|
|
72
|
-
* retired it).
|
|
73
|
-
*
|
|
74
|
-
* A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
|
|
75
|
-
* this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
|
|
76
|
-
* library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
|
|
77
|
-
* task can legitimately be pinned to one — the docs-refresh preset spawns its tasks onto
|
|
78
|
-
* `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
|
|
79
|
-
* fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
|
|
80
|
-
* an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
|
|
81
|
-
* show is still the task's answer, and the backend resolves the id for the run.
|
|
82
|
-
*/
|
|
83
|
-
const defaultPipeline = computed<{ id: string; name: string } | undefined>(() => {
|
|
84
|
-
const pinnedId = task.value?.pipelineId
|
|
85
|
-
if (pinnedId) {
|
|
86
|
-
return (
|
|
87
|
-
pipelines.getPipeline(pinnedId) ?? {
|
|
88
|
-
id: pinnedId,
|
|
89
|
-
// The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
|
|
90
|
-
// pin still names itself here; the generic label covers a pin to something this build's
|
|
91
|
-
// catalog does not know at all.
|
|
92
|
-
name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
|
|
93
|
-
}
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
// No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
|
|
97
|
-
// an operator who named one said something a tier cannot overrule.
|
|
98
|
-
const declared = pipelines.declaredDefaultId('interactive')
|
|
99
|
-
return (
|
|
100
|
-
pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
|
|
101
|
-
pipelines.pipelines[0]
|
|
102
|
-
)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
66
|
/** The PR the implementer agent opened for this task, if any. */
|
|
106
67
|
const pr = computed(() => task.value?.pullRequest)
|
|
107
68
|
const prLabel = computed(() =>
|
|
108
69
|
pr.value?.number ? t('board.task.prNumber', { number: pr.value.number }) : t('board.task.pr'),
|
|
109
70
|
)
|
|
110
71
|
|
|
111
|
-
/**
|
|
112
|
-
* Reading the result starts at the OUTCOME summary (what changed in product terms, with the
|
|
113
|
-
* captured evidence), and the pull request is one click inside it. In BASIC mode that replaces
|
|
114
|
-
* the card's raw PR chip: the diff is still exactly as reachable, through a surface that says
|
|
115
|
-
* what the diff is about first. Advanced mode keeps both, since a reader who wants the diff
|
|
116
|
-
* directly is the reader that tier is for.
|
|
117
|
-
*
|
|
118
|
-
* Offered only where there is something to read, asked of the SAME reduction the window renders
|
|
119
|
-
* and the inspector's button gates on: a card that offered "read the result" on a task whose
|
|
120
|
-
* every section says "nothing here" would teach people the surface is empty. A task marked done
|
|
121
|
-
* by hand, with no pull request and no run, is that task.
|
|
122
|
-
*/
|
|
123
72
|
const laneView = useLaneViewStore()
|
|
124
|
-
const outcomeReadable = computed(() => {
|
|
125
|
-
const block = task.value
|
|
126
|
-
if (!block) return false
|
|
127
|
-
return hasOutcomeToShow(
|
|
128
|
-
composeRunOutcome({ block, instance: execution.getInstance(block.executionId) ?? null }),
|
|
129
|
-
)
|
|
130
|
-
})
|
|
131
73
|
/**
|
|
132
|
-
* The card's raw pull-request chip
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* the
|
|
74
|
+
* The card's raw pull-request chip. Reading the result starts at the OUTCOME summary (what changed
|
|
75
|
+
* in product terms, with the captured evidence), and the pull request is one click inside it, so
|
|
76
|
+
* BASIC mode drops the chip in favour of the outcome card carrying the same link at the top: the
|
|
77
|
+
* diff is still exactly as reachable, through a surface that says what the diff is about first.
|
|
78
|
+
* Advanced mode keeps both, since a reader who wants the diff directly is the reader that tier is
|
|
79
|
+
* for.
|
|
80
|
+
*
|
|
81
|
+
* Written as the INVARIANT ("the diff never stops being reachable from this card") rather than as
|
|
82
|
+
* `isAdvanced` alone, so the tier can only ever reorder two routes and never remove the last one:
|
|
83
|
+
* where the outcome card is not offered, the chip stays in both tiers.
|
|
137
84
|
*/
|
|
138
85
|
const showPrChip = computed(
|
|
139
86
|
() => Boolean(pr.value) && (uiMode.isAdvanced || !outcomeReadable.value),
|
|
@@ -149,10 +96,6 @@ const showPrChip = computed(
|
|
|
149
96
|
const showModuleChip = computed(
|
|
150
97
|
() => Boolean(task.value?.moduleName) && laneView.groupKey !== 'module',
|
|
151
98
|
)
|
|
152
|
-
function openOutcome() {
|
|
153
|
-
ui.openOutcome(props.taskId, task.value?.executionId ?? null)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
99
|
// This task's current agent run (if any). A failed run must surface the shared
|
|
157
100
|
// failure banner + retry — NOT a stuck progress bar — so the card never looks
|
|
158
101
|
// like it's still working after the run has terminated.
|
|
@@ -164,129 +107,6 @@ const runFailed = computed(() => agentRun.value?.status === 'failed')
|
|
|
164
107
|
const recurring = useRecurringPipelinesStore()
|
|
165
108
|
const schedule = computed(() => recurring.byBlock(props.taskId))
|
|
166
109
|
|
|
167
|
-
// Optimistic "Start": flip the button into a spinning "Starting…" state the
|
|
168
|
-
// instant it's clicked, before the server confirms. The button naturally
|
|
169
|
-
// unmounts once the stream pushes the block into `in_progress`; if the start
|
|
170
|
-
// call faults we revert and surface a toast.
|
|
171
|
-
const starting = ref(false)
|
|
172
|
-
|
|
173
|
-
async function run() {
|
|
174
|
-
if (!runnable.value) {
|
|
175
|
-
toast.add({
|
|
176
|
-
title: t('board.task.blockedByDependenciesTitle'),
|
|
177
|
-
description: t('board.task.waitingOn', { deps: unmet.value.map((d) => d.title).join(', ') }),
|
|
178
|
-
icon: 'i-lucide-lock',
|
|
179
|
-
})
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
const pipeline = defaultPipeline.value
|
|
183
|
-
if (!pipeline) {
|
|
184
|
-
toast.add({
|
|
185
|
-
title: t('board.task.noPipelineTitle'),
|
|
186
|
-
description: t('board.task.noPipelineBody'),
|
|
187
|
-
})
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
starting.value = true
|
|
191
|
-
// false ⇒ the run never started (the user cancelled the personal-password prompt, or
|
|
192
|
-
// the start was refused — the store surfaces the actionable toast itself). Revert the
|
|
193
|
-
// optimistic state; on success the button unmounts once the stream pushes in_progress.
|
|
194
|
-
const started = await execution.start(props.taskId, pipeline)
|
|
195
|
-
if (started) {
|
|
196
|
-
// Confirm the (optimistic) start landed — the button unmounts once the stream pushes
|
|
197
|
-
// in_progress, so without this the successful action gives no feedback. A sandboxed start
|
|
198
|
-
// says so here rather than borrowing the live wording: this is the moment the reader learns
|
|
199
|
-
// what they just started, and the two runs differ in what they will end up doing.
|
|
200
|
-
toast.add({
|
|
201
|
-
title: sandboxed.value ? t('board.dryRunToast.title') : t('board.task.startedToast.title'),
|
|
202
|
-
description: sandboxed.value
|
|
203
|
-
? t('board.dryRunToast.body', { name: pipeline.name })
|
|
204
|
-
: t('board.task.startedToast.body', { name: pipeline.name }),
|
|
205
|
-
color: sandboxed.value ? 'warning' : 'success',
|
|
206
|
-
icon: sandboxed.value ? 'i-lucide-shield' : 'i-lucide-play',
|
|
207
|
-
})
|
|
208
|
-
} else {
|
|
209
|
-
starting.value = false
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function review() {
|
|
214
|
-
ui.select(props.taskId)
|
|
215
|
-
ui.focus(props.taskId)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function merge() {
|
|
219
|
-
// Merging a PR into its base is consequential and effectively irreversible — gate it behind a
|
|
220
|
-
// confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
|
|
221
|
-
const ok = await confirm({
|
|
222
|
-
title: t('board.task.mergeConfirm.title'),
|
|
223
|
-
description: t('board.task.mergeConfirm.body'),
|
|
224
|
-
confirmLabel: t('board.task.mergeConfirm.confirm'),
|
|
225
|
-
icon: 'i-lucide-git-merge',
|
|
226
|
-
})
|
|
227
|
-
if (!ok) return
|
|
228
|
-
await execution.mergePr(props.taskId)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// A `blocked` task is waiting on a human for one of two reasons — an agent-raised
|
|
232
|
-
// decision OR an approval gate — and both must surface here (a failed run is shown
|
|
233
|
-
// separately by the AgentFailureCard above). The board previously only handled
|
|
234
|
-
// decisions, so an approval-gated task was a dead end: it read "Decision needed"
|
|
235
|
-
// (the old generic `blocked` label) with no badge and a click that did nothing.
|
|
236
|
-
// Read off the per-block index rather than scanning the workspace-wide list: this computed is
|
|
237
|
-
// mounted once per card and invalidated by every execution event, so a `find` over `openDecisions`
|
|
238
|
-
// cost O(cards x open gates) per event. `decisionsByBlock` is the index built for exactly this.
|
|
239
|
-
const pendingDecision = computed(() => execution.decisionsByBlock.get(props.taskId)?.[0])
|
|
240
|
-
// The async stage an iterative reviewer gate (requirements-review / clarity-review) is
|
|
241
|
-
// mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate
|
|
242
|
-
// needs NO human action, so its approval is suppressed below and a working indicator
|
|
243
|
-
// shows instead.
|
|
244
|
-
const reviewStage = computed(() => reviews.stageForBlock(props.taskId))
|
|
245
|
-
const reviewStageLabel = computed(() =>
|
|
246
|
-
reviewStage.value === 'incorporating'
|
|
247
|
-
? t('board.task.incorporatingAnswers')
|
|
248
|
-
: reviewStage.value === 'reviewing'
|
|
249
|
-
? t('board.task.reReviewing')
|
|
250
|
-
: reviewStage.value === 'recommending'
|
|
251
|
-
? t('board.task.recommending')
|
|
252
|
-
: null,
|
|
253
|
-
)
|
|
254
|
-
const pendingApproval = computed(() => {
|
|
255
|
-
const a = execution.approvalsByBlock.get(props.taskId)?.[0]
|
|
256
|
-
// A reviewer gate whose review is incorporating / re-reviewing in the driver is doing
|
|
257
|
-
// background work, not awaiting a human — don't surface it as "Approval needed".
|
|
258
|
-
if (a && reviews.isBackground(a.agentKind, props.taskId)) return undefined
|
|
259
|
-
return a
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
/** What this blocked task actually needs from a human — drives the card's label,
|
|
263
|
-
* pulse and action. Decision takes precedence over approval (a step never holds
|
|
264
|
-
* both at once; this is just a stable order). Null when nothing is pending. */
|
|
265
|
-
const attention = computed<{
|
|
266
|
-
label: string
|
|
267
|
-
icon: string
|
|
268
|
-
action: string
|
|
269
|
-
open: () => void
|
|
270
|
-
} | null>(() => {
|
|
271
|
-
const d = pendingDecision.value
|
|
272
|
-
if (d)
|
|
273
|
-
return {
|
|
274
|
-
label: t('board.task.decisionNeeded'),
|
|
275
|
-
icon: 'i-lucide-circle-help',
|
|
276
|
-
action: t('board.task.resolve'),
|
|
277
|
-
open: () => ui.openDecision(d.instanceId, d.decision.id),
|
|
278
|
-
}
|
|
279
|
-
const a = pendingApproval.value
|
|
280
|
-
if (a)
|
|
281
|
-
return {
|
|
282
|
-
label: t('board.task.approvalNeeded'),
|
|
283
|
-
icon: 'i-lucide-shield-check',
|
|
284
|
-
action: t('board.task.approve'),
|
|
285
|
-
open: () => ui.openApprovalDetail(a.instanceId, a.approval.id),
|
|
286
|
-
}
|
|
287
|
-
return null
|
|
288
|
-
})
|
|
289
|
-
|
|
290
110
|
/** Specific header copy: a failed run reads "Failed", a parked task reads its
|
|
291
111
|
* decision/approval reason, otherwise the generic status label. */
|
|
292
112
|
const statusText = computed(() =>
|
|
@@ -295,6 +115,11 @@ const statusText = computed(() =>
|
|
|
295
115
|
: (reviewStageLabel.value ?? attention.value?.label ?? statusMeta.value?.label ?? ''),
|
|
296
116
|
)
|
|
297
117
|
|
|
118
|
+
function review() {
|
|
119
|
+
ui.select(props.taskId)
|
|
120
|
+
ui.focus(props.taskId)
|
|
121
|
+
}
|
|
122
|
+
|
|
298
123
|
// Clicking the card body only selects the task (opening the inspector so the human can
|
|
299
124
|
// interact with it). Whatever the task is parked on — a decision, an approval, or the
|
|
300
125
|
// requirements review — is opened explicitly via the action button below, never by a
|
|
@@ -461,10 +286,11 @@ function selectTask() {
|
|
|
461
286
|
size="xs"
|
|
462
287
|
:icon="!runnable ? 'i-lucide-lock' : sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
|
|
463
288
|
:loading="starting"
|
|
464
|
-
:disabled="!runnable || starting"
|
|
289
|
+
:disabled="!runnable || starting || !canExecute"
|
|
465
290
|
data-testid="task-start"
|
|
466
291
|
:title="
|
|
467
|
-
|
|
292
|
+
executeDeniedTitle ??
|
|
293
|
+
(!runnable
|
|
468
294
|
? t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
|
|
469
295
|
: sandboxed
|
|
470
296
|
? t('board.task.startPipelineDryRun', {
|
|
@@ -472,7 +298,7 @@ function selectTask() {
|
|
|
472
298
|
})
|
|
473
299
|
: t('board.task.startPipeline', {
|
|
474
300
|
name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
|
|
475
|
-
})
|
|
301
|
+
}))
|
|
476
302
|
"
|
|
477
303
|
@click.stop="run"
|
|
478
304
|
>
|
|
@@ -529,7 +355,14 @@ function selectTask() {
|
|
|
529
355
|
>
|
|
530
356
|
{{ t('board.task.review') }}
|
|
531
357
|
</UButton>
|
|
532
|
-
<UButton
|
|
358
|
+
<UButton
|
|
359
|
+
color="success"
|
|
360
|
+
size="xs"
|
|
361
|
+
icon="i-lucide-git-merge"
|
|
362
|
+
:disabled="!canExecute"
|
|
363
|
+
:title="executeDeniedTitle"
|
|
364
|
+
@click.stop="merge"
|
|
365
|
+
>
|
|
533
366
|
{{ t('board.task.merge') }}
|
|
534
367
|
</UButton>
|
|
535
368
|
</template>
|
|
@@ -12,6 +12,8 @@ const board = useBoardStore()
|
|
|
12
12
|
// the sidebar/command entries with zero edits here.
|
|
13
13
|
const { toolbarItems, invoke: invokeNav } = useNavContributions()
|
|
14
14
|
const execution = useExecutionStore()
|
|
15
|
+
const views = useWorkViewStore()
|
|
16
|
+
const queue = useWorkspaceQueueStore()
|
|
15
17
|
const workspace = useWorkspaceStore()
|
|
16
18
|
const workspaceSettings = useWorkspaceSettingsStore()
|
|
17
19
|
const services = useServicesStore()
|
|
@@ -104,19 +106,13 @@ const spendLabel = computed(() => {
|
|
|
104
106
|
}
|
|
105
107
|
return `${fmt(s.costSpent)} / ${fmt(s.costLimit)}`
|
|
106
108
|
})
|
|
107
|
-
|
|
109
|
+
/** The decisions this counts are in every service, so the queue opens on all of them. */
|
|
110
|
+
function openQueue() {
|
|
111
|
+
queue.clearFilter()
|
|
112
|
+
views.show('queue')
|
|
113
|
+
}
|
|
108
114
|
|
|
109
|
-
const
|
|
110
|
-
execution.openDecisions.map((d) => {
|
|
111
|
-
const b = board.getBlock(d.blockId)
|
|
112
|
-
return {
|
|
113
|
-
label: b?.title ?? t('common.block'),
|
|
114
|
-
description: d.decision.question,
|
|
115
|
-
icon: 'i-lucide-circle-help',
|
|
116
|
-
onSelect: () => ui.openDecision(d.instanceId, d.decision.id),
|
|
117
|
-
}
|
|
118
|
-
}),
|
|
119
|
-
)
|
|
115
|
+
const spendColor = computed(() => (spend.value?.exceeded ? 'error' : 'neutral'))
|
|
120
116
|
</script>
|
|
121
117
|
|
|
122
118
|
<template>
|
|
@@ -191,21 +187,23 @@ const decisionItems = computed(() =>
|
|
|
191
187
|
|
|
192
188
|
<USeparator orientation="vertical" class="mx-1 h-6" />
|
|
193
189
|
|
|
194
|
-
<!--
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
190
|
+
<!-- The decision count. It opens the queue view, which lists every decision with its Answer
|
|
191
|
+
button beside the rest of what needs a person (issue #2258). -->
|
|
192
|
+
<UButton
|
|
193
|
+
v-if="execution.pendingDecisionCount"
|
|
194
|
+
color="warning"
|
|
195
|
+
variant="soft"
|
|
196
|
+
size="sm"
|
|
197
|
+
icon="i-lucide-circle-help"
|
|
198
|
+
:title="t('views.openQueue')"
|
|
199
|
+
data-testid="decision-badge"
|
|
200
|
+
@click="openQueue"
|
|
201
|
+
>
|
|
202
|
+
{{ execution.pendingDecisionCount
|
|
203
|
+
}}<span class="hidden sm:inline"
|
|
204
|
+
> {{ t('board.toolbar.decisionWord', execution.pendingDecisionCount) }}</span
|
|
202
205
|
>
|
|
203
|
-
|
|
204
|
-
}}<span class="hidden sm:inline"
|
|
205
|
-
> {{ t('board.toolbar.decisionWord', execution.pendingDecisionCount) }}</span
|
|
206
|
-
>
|
|
207
|
-
</UButton>
|
|
208
|
-
</UDropdownMenu>
|
|
206
|
+
</UButton>
|
|
209
207
|
|
|
210
208
|
<!-- how every frame's swimlanes are ordered + grouped (an override; advanced tier) -->
|
|
211
209
|
<LaneViewControl />
|
|
@@ -33,10 +33,15 @@ import DefaultTestEnvBanner from '~/components/layout/DefaultTestEnvBanner.vue'
|
|
|
33
33
|
|
|
34
34
|
// The live-stream state the connection strip renders. Passed down rather than resolved here:
|
|
35
35
|
// the page owns the single stream instance, and creating another would open a second socket.
|
|
36
|
+
//
|
|
37
|
+
// `inFlow` is the queue view's placement: the same region, laid out in the page flow above the
|
|
38
|
+
// queue's columns instead of floating over a canvas, and without the toolbar, which is the
|
|
39
|
+
// canvas's own chrome (zoom, fit, lane view).
|
|
36
40
|
defineProps<{
|
|
37
41
|
connected: boolean
|
|
38
42
|
everConnected: boolean
|
|
39
43
|
connectionFailed: boolean
|
|
44
|
+
inFlow?: boolean
|
|
40
45
|
}>()
|
|
41
46
|
|
|
42
47
|
const ui = useUiStore()
|
|
@@ -44,7 +49,8 @@ const ui = useUiStore()
|
|
|
44
49
|
|
|
45
50
|
<template>
|
|
46
51
|
<div
|
|
47
|
-
class="pointer-events-none
|
|
52
|
+
class="pointer-events-none z-40 flex flex-col items-center gap-2 px-4"
|
|
53
|
+
:class="inFlow ? 'relative pt-3' : 'absolute inset-x-0 top-0 pt-3'"
|
|
48
54
|
>
|
|
49
55
|
<!-- Compact-viewport nav trigger: the SideBar is an off-canvas drawer below lg, so surface
|
|
50
56
|
a hamburger to open it. Out of the column's flow (it is a corner control, not a
|
|
@@ -62,7 +68,7 @@ const ui = useUiStore()
|
|
|
62
68
|
|
|
63
69
|
<!-- FIRST, and deliberately so: the toolbar is standing board chrome, and a tour step
|
|
64
70
|
anchors it. Below the advisories it would move every time one appears or clears. -->
|
|
65
|
-
<BoardToolbar />
|
|
71
|
+
<BoardToolbar v-if="!inFlow" />
|
|
66
72
|
|
|
67
73
|
<!-- Then the advisories, most urgent first. Ordering is by what the user loses by not
|
|
68
74
|
reading it now, not by how loud the card is.
|
|
@@ -21,6 +21,7 @@ import UserMenu from '~/components/auth/UserMenu.vue'
|
|
|
21
21
|
import { useViewport } from '~/composables/useViewport'
|
|
22
22
|
import type { NavContribution } from '~/modular/nav-contributions'
|
|
23
23
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
24
|
+
import WorkViewSwitcher from '~/components/layout/WorkViewSwitcher.vue'
|
|
24
25
|
|
|
25
26
|
const { t } = useI18n()
|
|
26
27
|
|
|
@@ -227,6 +228,10 @@ watch(
|
|
|
227
228
|
</div>
|
|
228
229
|
|
|
229
230
|
<div class="contents" @click="onNavAction">
|
|
231
|
+
<!-- The two views of the board (issue #2258), first because they decide what the main
|
|
232
|
+
area shows. -->
|
|
233
|
+
<WorkViewSwitcher :collapsed="railed" />
|
|
234
|
+
|
|
230
235
|
<!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
|
|
231
236
|
and reach every action below. -->
|
|
232
237
|
<UButton
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { WorkView } from '~/utils/workView'
|
|
3
|
+
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sidebar's "Views" section: the queue and the canvas, as two views of one board.
|
|
7
|
+
*
|
|
8
|
+
* A view SWITCHER, not a list of destinations, which is why it is not a `nav` contribution: a
|
|
9
|
+
* contribution opens something, and these change what the main area shows, so they need the
|
|
10
|
+
* selected state a contribution has no shape for. Shown in every tier and every role: which view
|
|
11
|
+
* a person works in is their choice, and neither view is an override of the other.
|
|
12
|
+
*/
|
|
13
|
+
defineProps<{ collapsed: boolean }>()
|
|
14
|
+
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const views = useWorkViewStore()
|
|
17
|
+
const queue = useWorkspaceQueueStore()
|
|
18
|
+
|
|
19
|
+
const ITEMS: { view: WorkView; icon: string; labelKey: string }[] = [
|
|
20
|
+
{ view: 'queue', icon: 'i-lucide-list-checks', labelKey: 'views.queue' },
|
|
21
|
+
{ view: 'board', icon: 'i-lucide-map', labelKey: 'views.board' },
|
|
22
|
+
]
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<section data-testid="work-view-switcher">
|
|
27
|
+
<SectionLabel v-if="!collapsed" as="h2" class="mb-2 px-1">
|
|
28
|
+
{{ t('views.title') }}
|
|
29
|
+
</SectionLabel>
|
|
30
|
+
<div class="space-y-1.5">
|
|
31
|
+
<UButton
|
|
32
|
+
v-for="item in ITEMS"
|
|
33
|
+
:key="item.view"
|
|
34
|
+
:block="!collapsed"
|
|
35
|
+
:square="collapsed"
|
|
36
|
+
size="sm"
|
|
37
|
+
:color="views.view === item.view ? 'primary' : 'neutral'"
|
|
38
|
+
:variant="views.view === item.view ? 'soft' : 'ghost'"
|
|
39
|
+
:icon="item.icon"
|
|
40
|
+
class="w-full"
|
|
41
|
+
:class="collapsed ? 'justify-center' : 'justify-start'"
|
|
42
|
+
:aria-label="collapsed ? t(item.labelKey) : undefined"
|
|
43
|
+
:aria-current="views.view === item.view ? 'page' : undefined"
|
|
44
|
+
:title="collapsed ? t(item.labelKey) : undefined"
|
|
45
|
+
:data-testid="`work-view-${item.view}`"
|
|
46
|
+
@click="views.show(item.view)"
|
|
47
|
+
>
|
|
48
|
+
<template v-if="!collapsed">
|
|
49
|
+
<span class="flex-1 truncate text-start">{{ t(item.labelKey) }}</span>
|
|
50
|
+
<!-- The queue's count: tasks an agent cannot continue until a person acts. -->
|
|
51
|
+
<UBadge
|
|
52
|
+
v-if="item.view === 'queue' && queue.needsYouCount > 0"
|
|
53
|
+
size="sm"
|
|
54
|
+
color="warning"
|
|
55
|
+
variant="subtle"
|
|
56
|
+
data-testid="work-view-queue-count"
|
|
57
|
+
>
|
|
58
|
+
{{ queue.needsYouCount }}
|
|
59
|
+
</UBadge>
|
|
60
|
+
</template>
|
|
61
|
+
</UButton>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
</template>
|