@cat-factory/app 0.275.0 → 0.277.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 +89 -2
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +13 -1
- package/app/components/binaryOutput/BinaryOutputReport.vue +15 -1
- package/app/components/binaryOutput/StoredAssetView.vue +134 -0
- package/app/components/board/AddTaskModal.vue +9 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +24 -10
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/palettes/PipelinePurposeSelect.vue +1 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +61 -1
- package/app/composables/api/visualConfirm.ts +11 -2
- package/app/composables/useArtifactBlobs.spec.ts +76 -0
- package/app/composables/useArtifactBlobs.ts +25 -4
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/binaryCandidates.ts +19 -1
- package/app/utils/binaryOutput.ts +13 -0
- package/app/utils/catalog.ts +6 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/i18n/locales/de.json +91 -5
- package/i18n/locales/en.json +91 -5
- package/i18n/locales/es.json +91 -5
- package/i18n/locales/fr.json +91 -5
- package/i18n/locales/he.json +91 -5
- package/i18n/locales/it.json +91 -5
- package/i18n/locales/ja.json +91 -5
- package/i18n/locales/pl.json +91 -5
- package/i18n/locales/tr.json +91 -5
- package/i18n/locales/uk.json +91 -5
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
15
15
|
- [What it is](#what-it-is)
|
|
16
16
|
- [Tech stack](#tech-stack)
|
|
17
17
|
- [Layout](#layout)
|
|
18
|
+
- [Task swimlanes](#task-swimlanes)
|
|
18
19
|
- [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
|
|
19
20
|
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
20
21
|
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
|
|
@@ -240,6 +241,89 @@ surface rather than a toast, and are tracked as G4 in
|
|
|
240
241
|
|
|
241
242
|
A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
|
|
242
243
|
|
|
244
|
+
## Task swimlanes
|
|
245
|
+
|
|
246
|
+
A service frame lays its tasks out in **status lanes**, not at coordinates. Three lanes a reader
|
|
247
|
+
works in (`not_started`, `in_progress`, `needs_you`) plus a collapsed **Done** strip beneath them.
|
|
248
|
+
The vocabulary, the classification and the Done caps are `app/utils/swimlanes.ts`; the ordering and
|
|
249
|
+
grouping are `app/utils/laneSort.ts`; `composables/useFrameLanes.ts` is the only store-facing half.
|
|
250
|
+
|
|
251
|
+
**A lane is a CLAIM, which is a higher bar than a badge.** A mislabelled badge sits beside the
|
|
252
|
+
truth; a card filed in the wrong lane states something false _and_ hides the card from the column
|
|
253
|
+
its reader was scanning. Three rules follow, and they are what the tests pin:
|
|
254
|
+
|
|
255
|
+
- **The classification is TOTAL.** `BASE_REASON_BY_STATUS` is a `Record<BlockStatus, …>`, so a new
|
|
256
|
+
status fails the build; and a status the TYPE says is impossible while the DATABASE still holds it
|
|
257
|
+
(a retired picklist member on an old row) resolves to `unclassified` → `needs_you`, never to
|
|
258
|
+
`undefined`, which would drop the card out of every lane with nothing left to say it existed.
|
|
259
|
+
- **An imprecise reason beats a wrong lane.** The SPA models only decisions and approvals as global
|
|
260
|
+
per-block selectors; a judge / human-test / visual-confirmation / fork / follow-up / input-gate
|
|
261
|
+
park is reachable only by drilling into a step. But every one of them parks the RUN at
|
|
262
|
+
`status: 'blocked'`, and that coarse marker places the card correctly even when this layer cannot
|
|
263
|
+
name the surface. Such a park reports `parked` — named but imprecise, never demoted to "in flight".
|
|
264
|
+
- **The reason is a separate answer from the lane.** The lane says "look at this"; the reason says
|
|
265
|
+
"what would I do". `failed`, `budget_paused` and `approval` all stop work dead and need three
|
|
266
|
+
unrelated actions, which is why `blocking_reason` is a grouping and why a lane header can name it.
|
|
267
|
+
|
|
268
|
+
**Ordering defaults per lane** (`smart`), because the actionable order genuinely differs by column:
|
|
269
|
+
what can be started now / what has gone quiet / what has waited longest. Two rules bind any new
|
|
270
|
+
comparator: an **unknown timestamp sorts last in BOTH directions** (a run that reported no activity
|
|
271
|
+
is not stale and not fresh, and ranking it as either invents a fact), and **every comparator ends on
|
|
272
|
+
board order**, since these re-run on every live push and an unresolved tie makes the lane shuffle
|
|
273
|
+
itself. `nullsLast` is the single place the first rule lives.
|
|
274
|
+
|
|
275
|
+
**Sizing is decoupled from content** (`utils/laneGeometry.ts`). A lane scrolls; it does not grow. A
|
|
276
|
+
service with 300 open tasks gets the same frame as one with three, which is what the old free layout
|
|
277
|
+
could not do — a busy service grew until it dwarfed its neighbours.
|
|
278
|
+
|
|
279
|
+
Three consumers have to agree about that size, which is why the module exports two FUNCTIONS rather
|
|
280
|
+
than leaving each to do the arithmetic: `frameContentSize` is the frame's floor (read by
|
|
281
|
+
`contentSize`, and by `framePlacement.EMPTY_FRAME_SIZE`, which has to reserve a spot for a frame
|
|
282
|
+
that does not exist yet and so cannot measure it), and `laneBodyHeightIn` is its inverse, handing a
|
|
283
|
+
lane whatever the frame's ACTUAL size leaves it. A dragged border therefore grows the lanes rather
|
|
284
|
+
than leaving dead canvas below them, and a constant restating either answer is exactly what went
|
|
285
|
+
stale before. A frame with no children at all is the one that skips the lanes: it renders one "add
|
|
286
|
+
the first task" panel and is sized for that.
|
|
287
|
+
|
|
288
|
+
**Two preferences, deliberately different scopes.** The sort/group choice is per user, per browser
|
|
289
|
+
(`stores/laneView.ts`, persisted like the interface tier): it is personal and changes several times
|
|
290
|
+
an hour, and making it shared would let one person's triage sweep re-arrange everyone's board. The
|
|
291
|
+
Done lane's two **caps** are per-workspace settings (`doneLaneMaxItems`, `doneLaneRetentionDays`),
|
|
292
|
+
because what the board may show of a service's history is a shared decision. Both caps hide cards
|
|
293
|
+
only, and the lane reports the two drop counts separately: an age drop means "there is older
|
|
294
|
+
history", a cap drop means "there is more from this period".
|
|
295
|
+
|
|
296
|
+
**A task with no `completedAt` is exempt from the age cap**, not treated as ancient. Every block
|
|
297
|
+
merged before that column existed reads that way, and hiding history on the strength of a timestamp
|
|
298
|
+
nobody recorded would be the platform inferring a fact it does not have. The count cap still bounds
|
|
299
|
+
them, so the exemption cannot make the lane unbounded, and `undatedShown` says how many there are.
|
|
300
|
+
|
|
301
|
+
**A FRAME still has coordinates, and they live on its `WorkspaceMount`, not on the block** (one
|
|
302
|
+
shared service sits at a different spot on every board that mounts it), so every frame-returning read
|
|
303
|
+
projects through kernel's `applyMountLayout`. The resize path is where people hit a missed projection,
|
|
304
|
+
because a `size`-only edit is the one frame patch with no other visible effect: the SPA upserts the
|
|
305
|
+
authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.
|
|
306
|
+
|
|
307
|
+
**Dragging a card now only reparents** (`positioned: false` in `useBlockDrag`): between services,
|
|
308
|
+
and into or out of a module via a module group header's drop zone. Which LANE a card is in is not
|
|
309
|
+
something a drop can decide — the lane is derived from state, so dropping a not-started card on
|
|
310
|
+
"In progress" could only lie or silently do nothing. Because that drop target exists only while the
|
|
311
|
+
reader has grouping set to `module`, the inspector carries a **module picker** that does not depend
|
|
312
|
+
on the current grouping; module sub-frames no longer render as boxes, so without it the only route
|
|
313
|
+
into a module would be to change a view preference first.
|
|
314
|
+
|
|
315
|
+
**A move re-stamps the module the task DECLARES**, which is the one thing about that drag that is
|
|
316
|
+
not obvious. A task names its module twice: the block it is parented to, and `moduleName`, which
|
|
317
|
+
exists because the engine only materialises the module block on merge, so a task can name its module
|
|
318
|
+
before anything is its parent. Grouping reads the parent and falls back to the declared name — so a
|
|
319
|
+
card dragged OUT of a module and left still declaring it lands right back in the group it came from.
|
|
320
|
+
`BoardService.reparent` therefore rewrites the name from the destination container, exactly as it
|
|
321
|
+
already rewrote the `type` a task inherits from its frame, and the SPA's optimistic write predicts
|
|
322
|
+
the same answer through the shared `moduleNameInContainer` (`@cat-factory/contracts`) so the card
|
|
323
|
+
does not visibly jump when the response lands. "No module" is the EMPTY STRING on the wire, the way
|
|
324
|
+
every other clearable field spells a clear; `undefined` is dropped by `JSON.stringify` and reaches
|
|
325
|
+
the server as an empty patch.
|
|
326
|
+
|
|
243
327
|
## Roles (engineer / product manager / designer)
|
|
244
328
|
|
|
245
329
|
The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
|
|
@@ -1004,8 +1088,11 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
|
|
|
1004
1088
|
## Key UI surfaces
|
|
1005
1089
|
|
|
1006
1090
|
- **Board canvas** (`components/board`): `BoardCanvas` + `nodes/` (`BlockNode`,
|
|
1007
|
-
`
|
|
1008
|
-
|
|
1091
|
+
`FrameSwimlanes` / `TaskLane` / `LaneGroup` / `LaneTask`, `TaskCard`), dependency edges,
|
|
1092
|
+
the per-block `AgentFailureCard` /
|
|
1093
|
+
`AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. Tasks are laid out in status
|
|
1094
|
+
lanes rather than at coordinates (see [Task swimlanes](#task-swimlanes)); the board-level
|
|
1095
|
+
order/grouping override is `LaneViewControl` in the toolbar. A running task card expands
|
|
1009
1096
|
its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
|
|
1010
1097
|
on-screen card past the `steps` zoom band: the two grants are combined in the
|
|
1011
1098
|
`taskExpansion` store and driven by `useTaskExpansion`.
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
binaryCandidateView,
|
|
27
27
|
} from '~/utils/binaryCandidates'
|
|
28
28
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
29
|
+
import StoredAssetView from '~/components/binaryOutput/StoredAssetView.vue'
|
|
29
30
|
|
|
30
31
|
const execution = useExecutionStore()
|
|
31
32
|
const board = useBoardStore()
|
|
@@ -194,8 +195,19 @@ async function onKeep() {
|
|
|
194
195
|
data-testid="binary-candidate-card"
|
|
195
196
|
@click="toggle(row.id)"
|
|
196
197
|
>
|
|
198
|
+
<!-- Staged through the platform's OWN asset storage: we hold the bytes, so the card
|
|
199
|
+
renders them (and offers to open or save one) rather than waiting for a public
|
|
200
|
+
link the shipped storage never issues. Checked first because a candidate can
|
|
201
|
+
carry both, and ours is the one that is definitely still there. -->
|
|
202
|
+
<StoredAssetView
|
|
203
|
+
v-if="row.assetId"
|
|
204
|
+
:asset-id="row.assetId"
|
|
205
|
+
:content-type="row.contentType"
|
|
206
|
+
:label="row.label ?? row.subject"
|
|
207
|
+
class="mb-2"
|
|
208
|
+
/>
|
|
197
209
|
<img
|
|
198
|
-
v-if="row.previewUrl"
|
|
210
|
+
v-else-if="row.previewUrl"
|
|
199
211
|
:src="row.previewUrl"
|
|
200
212
|
:alt="row.label ?? row.id"
|
|
201
213
|
class="mb-2 max-h-56 w-full rounded object-contain"
|
|
@@ -16,6 +16,7 @@ import type { PipelineStep } from '~/types/execution'
|
|
|
16
16
|
import { BINARY_OUTPUT_STATE_KEYS, binaryOutputView } from '~/utils/binaryOutput'
|
|
17
17
|
import { binaryCandidateView } from '~/utils/binaryCandidates'
|
|
18
18
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
19
|
+
import StoredAssetView from '~/components/binaryOutput/StoredAssetView.vue'
|
|
19
20
|
|
|
20
21
|
// Two callers, one renderer — the same split `StepEffortReport` makes: the generic step-detail
|
|
21
22
|
// panel drops it in as a `card` (its own heading + border, among the other detail sections),
|
|
@@ -135,7 +136,13 @@ const state = computed(() => {
|
|
|
135
136
|
|
|
136
137
|
<!-- The artifacts. `location` is the service's OWN addressing — an object key, a path, a
|
|
137
138
|
URL — recorded verbatim and never interpreted, so it renders as copyable text and
|
|
138
|
-
never as a link.
|
|
139
|
+
never as a link.
|
|
140
|
+
The ONE exception is an artifact stored through the platform's own asset storage, where
|
|
141
|
+
the location is an id for bytes WE hold: there the row also shows the thing itself, and
|
|
142
|
+
offers to open or save it. That is not a reinterpretation of the location, it is the
|
|
143
|
+
join `binaryOutputView` already made (`row.assetId` is null unless the service is ours
|
|
144
|
+
AND the location is a well-formed id), and it is the difference between a report that
|
|
145
|
+
says where the work went and one that shows it. -->
|
|
139
146
|
<ul v-if="view.rows.length" class="space-y-1.5">
|
|
140
147
|
<li
|
|
141
148
|
v-for="(row, i) in view.rows"
|
|
@@ -144,6 +151,13 @@ const state = computed(() => {
|
|
|
144
151
|
data-testid="binary-output-artifact"
|
|
145
152
|
>
|
|
146
153
|
<CopyButton :text="row.location" class="absolute end-1 top-1" />
|
|
154
|
+
<StoredAssetView
|
|
155
|
+
v-if="row.assetId"
|
|
156
|
+
:asset-id="row.assetId"
|
|
157
|
+
:content-type="row.contentType"
|
|
158
|
+
:label="row.entity ?? row.description"
|
|
159
|
+
class="mb-2 pe-8"
|
|
160
|
+
/>
|
|
147
161
|
<code class="block break-all pe-8 font-mono text-[11px] text-slate-200">{{
|
|
148
162
|
row.location
|
|
149
163
|
}}</code>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A stored artifact the PLATFORM holds the bytes for: what it looks like, and the two things a
|
|
3
|
+
// person wants to do with it afterwards.
|
|
4
|
+
//
|
|
5
|
+
// This is the half of the media flow that only works because the shipped asset storage is our
|
|
6
|
+
// own. An artifact in an org's private bucket is a location string and, at best, whatever preview
|
|
7
|
+
// link that service chose to issue; one stored here is bytes we can serve, so the run's report can
|
|
8
|
+
// show the picture, open it full size, and hand the file over to be saved elsewhere.
|
|
9
|
+
//
|
|
10
|
+
// The bytes are behind the workspace's authenticated blob route, so an `<img src>` cannot point at
|
|
11
|
+
// them: they are fetched as a blob and turned into an object URL (`useArtifactBlobs`, the same
|
|
12
|
+
// composable the visual-confirmation gate uses). That is also why the DOWNLOAD is an `<a download>`
|
|
13
|
+
// over the object URL rather than a link to the API: a plain link would open an authenticated
|
|
14
|
+
// request the browser has no session header for.
|
|
15
|
+
//
|
|
16
|
+
// Whether the artifact renders as a picture is `rendersInlineAsImage` over the media type the
|
|
17
|
+
// SERVER SERVED, not the one the producing agent declared. The declaration is optional and
|
|
18
|
+
// model-authored: a step that stores a PNG and says nothing about it would render as a generic
|
|
19
|
+
// file, and one that mislabels a bundle as an image would render a broken `<img>` reporting
|
|
20
|
+
// itself as loaded, with no error affordance because the fetch genuinely succeeded. The served
|
|
21
|
+
// type is the same judgement the server already made when it decided whether to answer inline or
|
|
22
|
+
// as an attachment, so a row and the response behind it can no longer disagree. The declared type
|
|
23
|
+
// stays as the LABEL on a non-image row, which is what it is good for: it is the agent's own
|
|
24
|
+
// account of the file, where the served type only ever says image-or-not.
|
|
25
|
+
//
|
|
26
|
+
// Everything that is not a picture (a 3D model, an audio file, a PDF) is a legitimate outcome and
|
|
27
|
+
// NOT a failure: it renders as a named file with the same two actions, because the point of the
|
|
28
|
+
// surface is the file, not the thumbnail.
|
|
29
|
+
import { computed, onUnmounted, watch } from 'vue'
|
|
30
|
+
import { rendersInlineAsImage } from '@cat-factory/contracts'
|
|
31
|
+
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(
|
|
34
|
+
defineProps<{
|
|
35
|
+
/** The platform artifact id (`art_…`) resolved off the row's service + location. */
|
|
36
|
+
assetId: string
|
|
37
|
+
/** The media type the agent reported. Shown as the label on a non-image row; see above. */
|
|
38
|
+
contentType?: string | undefined
|
|
39
|
+
/** What to call the file a person saves, and the image's alt text. */
|
|
40
|
+
label?: string | undefined
|
|
41
|
+
/** Cap on the rendered preview's height, so a report row and a comparison card can differ. */
|
|
42
|
+
previewClass?: string
|
|
43
|
+
}>(),
|
|
44
|
+
{ contentType: undefined, label: undefined, previewClass: 'max-h-56' },
|
|
45
|
+
)
|
|
46
|
+
const { t } = useI18n()
|
|
47
|
+
|
|
48
|
+
const blobs = useArtifactBlobs()
|
|
49
|
+
onUnmounted(() => blobs.revokeAll())
|
|
50
|
+
|
|
51
|
+
// Re-resolve when the id changes rather than only on mount: one of these components is reused
|
|
52
|
+
// across the rows of a report that grows over a live run.
|
|
53
|
+
watch(
|
|
54
|
+
() => props.assetId,
|
|
55
|
+
(id) => void blobs.resolve(id),
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const url = computed(() => blobs.urlFor(props.assetId))
|
|
60
|
+
const status = computed(() => blobs.statusFor(props.assetId))
|
|
61
|
+
const isImage = computed(() => rendersInlineAsImage(blobs.typeFor(props.assetId)))
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The name the saved file takes. Derived from the agent's own label where there is one, because
|
|
65
|
+
* `art_01H…` tells the person who saves it nothing about what it is; sanitised to the characters
|
|
66
|
+
* a filename may safely carry, since the label is model-authored text going into a download.
|
|
67
|
+
*/
|
|
68
|
+
const filename = computed(() => {
|
|
69
|
+
const base = (props.label ?? '')
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
72
|
+
.replace(/^-+|-+$/g, '')
|
|
73
|
+
return base ? base.slice(0, 80) : props.assetId
|
|
74
|
+
})
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<div class="space-y-1.5" data-testid="stored-asset">
|
|
79
|
+
<img
|
|
80
|
+
v-if="isImage && url"
|
|
81
|
+
:src="url"
|
|
82
|
+
:alt="label ?? assetId"
|
|
83
|
+
class="w-full rounded object-contain"
|
|
84
|
+
:class="previewClass"
|
|
85
|
+
data-testid="stored-asset-preview"
|
|
86
|
+
/>
|
|
87
|
+
<!-- Not a picture, and that is an ordinary outcome for a media step: a mesh, a sound and a
|
|
88
|
+
PDF are all deliverables, so the row states what it is rather than showing a broken
|
|
89
|
+
frame. -->
|
|
90
|
+
<p
|
|
91
|
+
v-else-if="!isImage && url"
|
|
92
|
+
class="flex items-center gap-1.5 rounded bg-slate-800/60 px-2 py-1.5 text-[11px] text-slate-300"
|
|
93
|
+
data-testid="stored-asset-file"
|
|
94
|
+
>
|
|
95
|
+
<UIcon name="i-lucide-file" class="h-3.5 w-3.5 shrink-0" />
|
|
96
|
+
<span class="truncate">{{ contentType ?? t('binaryOutput.asset.unknownType') }}</span>
|
|
97
|
+
</p>
|
|
98
|
+
<p
|
|
99
|
+
v-else-if="status === 'error'"
|
|
100
|
+
class="flex items-center gap-1.5 text-[11px] text-amber-300"
|
|
101
|
+
data-testid="stored-asset-error"
|
|
102
|
+
>
|
|
103
|
+
<span>{{ t('binaryOutput.asset.loadFailed') }}</span>
|
|
104
|
+
<UButton size="xs" variant="link" @click.stop="blobs.retry(assetId)">
|
|
105
|
+
{{ t('binaryOutput.asset.retry') }}
|
|
106
|
+
</UButton>
|
|
107
|
+
</p>
|
|
108
|
+
<p v-else class="text-[11px] text-slate-500" data-testid="stored-asset-loading">
|
|
109
|
+
{{ t('binaryOutput.asset.loading') }}
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
<!-- The two things a person does with a delivered asset. Both hang off the object URL, so
|
|
113
|
+
they appear only once the bytes are in hand. -->
|
|
114
|
+
<div v-if="url" class="flex items-center gap-3 text-[11px]">
|
|
115
|
+
<a
|
|
116
|
+
:href="url"
|
|
117
|
+
target="_blank"
|
|
118
|
+
rel="noopener"
|
|
119
|
+
class="text-sky-300 hover:underline"
|
|
120
|
+
data-testid="stored-asset-open"
|
|
121
|
+
@click.stop
|
|
122
|
+
>{{ t('binaryOutput.asset.open') }}</a
|
|
123
|
+
>
|
|
124
|
+
<a
|
|
125
|
+
:href="url"
|
|
126
|
+
:download="filename"
|
|
127
|
+
class="text-sky-300 hover:underline"
|
|
128
|
+
data-testid="stored-asset-download"
|
|
129
|
+
@click.stop
|
|
130
|
+
>{{ t('binaryOutput.asset.download') }}</a
|
|
131
|
+
>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
@@ -104,6 +104,7 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
|
|
|
104
104
|
icon: 'i-lucide-clipboard-check',
|
|
105
105
|
},
|
|
106
106
|
{ value: 'ralph', label: t('board.addTask.types.ralph'), icon: 'i-lucide-infinity' },
|
|
107
|
+
{ value: 'media', label: t('board.addTask.types.media'), icon: 'i-lucide-image-plus' },
|
|
107
108
|
{ value: 'recurring', label: t('board.addTask.types.recurring'), icon: 'i-lucide-repeat' },
|
|
108
109
|
]
|
|
109
110
|
// A document repository only accepts document/spike tasks (see BoardService.addTask).
|
|
@@ -400,16 +401,20 @@ const selectablePipelines = computed(() =>
|
|
|
400
401
|
// must appear in the form BEFORE creation:
|
|
401
402
|
// - `ralph` needs its preset so the per-task validation command + iteration budget the `ralph`
|
|
402
403
|
// agent contributes surface for editing ("choose at run time" would be a dead end);
|
|
403
|
-
// - a `document` task defaults to `pl_document
|
|
404
|
-
// purpose-narrowed picker (the `purpose` gate hides every
|
|
405
|
-
// is never rendered empty.
|
|
404
|
+
// - a `document` task defaults to `pl_document`, a `review` task to `pl_review` and a `media`
|
|
405
|
+
// task to `pl_media` so their purpose-narrowed picker (the `purpose` gate hides every pipeline
|
|
406
|
+
// of another purpose) is never rendered empty.
|
|
406
407
|
// The other typed default (spike) carries no up-front config and doesn't narrow its picker, so the
|
|
407
408
|
// modal leaves `pipelineId` unset and `BoardService` applies the backend type-default at creation.
|
|
408
|
-
// Keep these ids in step with the backend helper.
|
|
409
|
+
// Keep these ids in step with the backend helper. The test for membership is the NARROWING, not the
|
|
410
|
+
// config: a type whose picker `pipelineAllowedForTaskType` reduces to one purpose has to name its
|
|
411
|
+
// default here, because every fallback below is a BUILD-purpose pipeline the narrowed picker then
|
|
412
|
+
// rejects, leaving the form open on an empty selection.
|
|
409
413
|
const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
410
414
|
ralph: 'pl_ralph',
|
|
411
415
|
document: 'pl_document',
|
|
412
416
|
review: 'pl_review',
|
|
417
|
+
media: 'pl_media',
|
|
413
418
|
}
|
|
414
419
|
/**
|
|
415
420
|
* The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
LANE_GROUP_KEYS,
|
|
4
|
+
LANE_SORT_KEYS,
|
|
5
|
+
type LaneGroupKey,
|
|
6
|
+
type LaneSortKey,
|
|
7
|
+
} from '~/utils/laneSort'
|
|
8
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The board-level override for how every frame's swimlanes are ordered and grouped.
|
|
12
|
+
*
|
|
13
|
+
* It is an OVERRIDE, which is what makes it an advanced-tier affordance: hidden, what remains is
|
|
14
|
+
* exactly the `smart` per-lane order and no grouping — the default it would otherwise have shown.
|
|
15
|
+
* The gate is `showOverrideField` rather than `isAdvanced` alone, because the preference persists
|
|
16
|
+
* per browser: someone who picks "by severity" in advanced mode and then switches to basic must
|
|
17
|
+
* still be able to see and clear it, or the board would be ordered by a rule they cannot find.
|
|
18
|
+
*/
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
const uiMode = useUiModeStore()
|
|
21
|
+
const laneView = useLaneViewStore()
|
|
22
|
+
|
|
23
|
+
// The DEFAULT values are passed as `undefined` rather than as themselves: `showOverrideField`
|
|
24
|
+
// reveals the control as soon as any value it edits is set, and `smart`/`none` being set is
|
|
25
|
+
// exactly the state where there is nothing to reveal.
|
|
26
|
+
const visible = computed(() =>
|
|
27
|
+
showOverrideField(
|
|
28
|
+
uiMode.isAdvanced,
|
|
29
|
+
laneView.sortKey === 'smart' ? undefined : laneView.sortKey,
|
|
30
|
+
laneView.groupKey === 'none' ? undefined : laneView.groupKey,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const sortItems = computed(() =>
|
|
35
|
+
LANE_SORT_KEYS.map((key) => ({
|
|
36
|
+
label: t(`board.lanes.sort.${key}`),
|
|
37
|
+
icon: laneView.sortKey === key ? 'i-lucide-check' : undefined,
|
|
38
|
+
onSelect: () => laneView.setSortKey(key as LaneSortKey),
|
|
39
|
+
})),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const groupItems = computed(() =>
|
|
43
|
+
LANE_GROUP_KEYS.map((key) => ({
|
|
44
|
+
label: t(`board.lanes.group.${key}`),
|
|
45
|
+
icon: laneView.groupKey === key ? 'i-lucide-check' : undefined,
|
|
46
|
+
onSelect: () => laneView.setGroupKey(key as LaneGroupKey),
|
|
47
|
+
})),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One menu, two labelled sections, plus a reset. Grouped rather than two adjacent buttons because
|
|
52
|
+
* the two choices are read together — "the newest bugs, by module" is one intent — and because the
|
|
53
|
+
* toolbar has no room for two more controls at small widths.
|
|
54
|
+
*/
|
|
55
|
+
const items = computed(() => [
|
|
56
|
+
[{ label: t('board.lanes.sort.heading'), type: 'label' as const }],
|
|
57
|
+
sortItems.value,
|
|
58
|
+
[{ label: t('board.lanes.group.heading'), type: 'label' as const }],
|
|
59
|
+
groupItems.value,
|
|
60
|
+
...(laneView.hasOverride
|
|
61
|
+
? [
|
|
62
|
+
[
|
|
63
|
+
{
|
|
64
|
+
label: t('board.lanes.resetView'),
|
|
65
|
+
icon: 'i-lucide-rotate-ccw',
|
|
66
|
+
onSelect: () => laneView.reset(),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
]
|
|
70
|
+
: []),
|
|
71
|
+
])
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<UDropdownMenu v-if="visible" :items="items">
|
|
76
|
+
<UButton
|
|
77
|
+
color="neutral"
|
|
78
|
+
:variant="laneView.hasOverride ? 'soft' : 'ghost'"
|
|
79
|
+
size="sm"
|
|
80
|
+
icon="i-lucide-arrow-down-up"
|
|
81
|
+
:title="t('board.lanes.viewTitle')"
|
|
82
|
+
data-testid="lane-view-control"
|
|
83
|
+
>
|
|
84
|
+
<span class="hidden sm:inline">{{ t('board.lanes.viewTitle') }}</span>
|
|
85
|
+
</UButton>
|
|
86
|
+
</UDropdownMenu>
|
|
87
|
+
</template>
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
3
|
import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
|
|
4
4
|
import DecisionBadge from './DecisionBadge.vue'
|
|
5
|
-
import
|
|
5
|
+
import FrameSwimlanes from './FrameSwimlanes.vue'
|
|
6
6
|
import InitiativeCard from './InitiativeCard.vue'
|
|
7
|
-
import ModuleFrame from './ModuleFrame.vue'
|
|
8
7
|
import ResizeGrips from './ResizeGrips.vue'
|
|
9
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
10
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
11
10
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
11
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
12
|
import { useViewport } from '~/composables/useViewport'
|
|
13
|
+
import { laneBodyHeightIn } from '~/utils/laneGeometry'
|
|
14
14
|
|
|
15
15
|
// Vue Flow passes the node's `id` and `data` as props to custom node components.
|
|
16
16
|
// Only frames are rendered as board nodes; their tasks live inside the card.
|
|
@@ -43,7 +43,9 @@ const isShared = computed(() => services.isSharedFrame(props.id))
|
|
|
43
43
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
44
44
|
|
|
45
45
|
// ---- this service's children (tasks + modules) -----------------------------
|
|
46
|
-
|
|
46
|
+
// No `directTasks` here: the swimlanes take EVERY task under the frame, its modules' included,
|
|
47
|
+
// because a module is a grouping inside a lane now rather than a box of its own. `modules` is
|
|
48
|
+
// still read for the composition line's module count.
|
|
47
49
|
const modules = computed(() => board.modulesOf(props.id))
|
|
48
50
|
const initiativeBlocks = computed(() => board.initiativesOf(props.id))
|
|
49
51
|
const allTasks = computed(() => board.allTasksUnder(props.id))
|
|
@@ -54,6 +56,9 @@ const hasTasks = computed(
|
|
|
54
56
|
)
|
|
55
57
|
const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
|
|
56
58
|
const canvas = computed(() => board.containerSize(props.id))
|
|
59
|
+
// A lane's scroll viewport fills whatever the frame's actual size leaves it, so dragging the
|
|
60
|
+
// frame's border gives the reader more of the lane rather than dead canvas beneath it.
|
|
61
|
+
const laneBodyHeight = computed(() => laneBodyHeightIn(canvas.value, initiativeBlocks.value.length))
|
|
57
62
|
|
|
58
63
|
// Frame status is derived from its tasks — services never reach "done".
|
|
59
64
|
const frameStatus = computed<BlockStatus>(() => board.frameStatus(props.id))
|
|
@@ -273,7 +278,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
273
278
|
/>
|
|
274
279
|
</div>
|
|
275
280
|
|
|
276
|
-
<!--
|
|
281
|
+
<!-- ============= The service card: the initiative band + task swimlanes =============
|
|
277
282
|
There is no chip or compact variant: a service is always expanded to its task canvas, at
|
|
278
283
|
every zoom level, so panning is a fixed layout and zooming has no expand/collapse
|
|
279
284
|
transition to snap on. The two gated-off branches that used to sit here (a far-zoom chip
|
|
@@ -501,15 +506,24 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
501
506
|
</div>
|
|
502
507
|
</div>
|
|
503
508
|
|
|
504
|
-
<!--
|
|
509
|
+
<!-- The frame's canvas. Tasks are laid out in status swimlanes rather than at
|
|
510
|
+
coordinates, so this is a fixed-size viewport (each lane scrolls) instead of the 2D
|
|
511
|
+
free-drag surface it used to be. `data-drop-zone` stays on the outer box so a drop
|
|
512
|
+
in the padding between lanes still resolves to this service rather than falling
|
|
513
|
+
through to nothing; each lane body carries the same zone for drops inside it. -->
|
|
505
514
|
<div
|
|
506
515
|
:data-drop-zone="block.id"
|
|
507
|
-
class="nodrag relative rounded-xl bg-slate-950/40"
|
|
508
|
-
:style="{ width: canvas.w + 'px',
|
|
516
|
+
class="nodrag relative rounded-xl bg-slate-950/40 p-2"
|
|
517
|
+
:style="{ width: canvas.w + 'px', minHeight: canvas.h + 'px' }"
|
|
509
518
|
>
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
<
|
|
519
|
+
<!-- Initiatives sit in a wrapping band above the lanes: they are containers of work,
|
|
520
|
+
not units of it, so they belong in no status lane. -->
|
|
521
|
+
<div v-if="initiativeBlocks.length" class="mb-2 flex flex-wrap gap-2">
|
|
522
|
+
<InitiativeCard v-for="i in initiativeBlocks" :key="i.id" :block-id="i.id" />
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<FrameSwimlanes v-if="hasTasks" :frame-id="block.id" :lane-body-height="laneBodyHeight" />
|
|
526
|
+
|
|
513
527
|
<button
|
|
514
528
|
v-if="!hasTasks && access.canWriteBoard.value"
|
|
515
529
|
type="button"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LaneGroup from './LaneGroup.vue'
|
|
3
|
+
import TaskLane from './TaskLane.vue'
|
|
4
|
+
import { useFrameLanes } from '~/composables/useFrameLanes'
|
|
5
|
+
import { LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
6
|
+
import { LANE_META } from '~/utils/swimlanes'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A service frame's tasks, laid out in status lanes.
|
|
10
|
+
*
|
|
11
|
+
* Three live lanes side by side, then the Done lane as a full-width strip beneath them rather
|
|
12
|
+
* than a fourth column. A collapsed column cannot state its own count without turning its label
|
|
13
|
+
* sideways, and giving the archive a quarter of the width permanently would take it from the
|
|
14
|
+
* three lanes a reader actually works in. As a strip it is one row when shut and a wide grid when
|
|
15
|
+
* opened, which is also the better shape for scanning history.
|
|
16
|
+
*/
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
frameId: string
|
|
19
|
+
/** A live lane's scroll viewport, resolved by the frame from its own size. */
|
|
20
|
+
laneBodyHeight: number
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const laneView = useLaneViewStore()
|
|
25
|
+
const { lanes, doneSelection } = useFrameLanes(computed(() => props.frameId))
|
|
26
|
+
|
|
27
|
+
const liveLanes = computed(() => lanes.value.filter((l) => l.lane !== 'done'))
|
|
28
|
+
const doneLane = computed(() => lanes.value.find((l) => l.lane === 'done'))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the Done lane is holding back, as one line.
|
|
32
|
+
*
|
|
33
|
+
* The two counts are reported separately because they tell a reader different things: an age
|
|
34
|
+
* drop means "there is older history, look further back", a cap drop means "there is more from
|
|
35
|
+
* this same period". Reporting nothing at all would be the worse failure — a truncated archive
|
|
36
|
+
* would read exactly like a complete one.
|
|
37
|
+
*/
|
|
38
|
+
const withheldNote = computed(() => {
|
|
39
|
+
const { hiddenByAge, hiddenByCap, undatedShown } = doneSelection.value
|
|
40
|
+
const parts: string[] = []
|
|
41
|
+
if (hiddenByCap > 0) parts.push(t('board.lanes.done.hiddenByCap', { count: hiddenByCap }))
|
|
42
|
+
if (hiddenByAge > 0) parts.push(t('board.lanes.done.hiddenByAge', { count: hiddenByAge }))
|
|
43
|
+
// Blocks merged before completion dates were recorded have no honest age, so the age cap
|
|
44
|
+
// cannot apply to them. Saying so stops the window looking tighter than it is.
|
|
45
|
+
if (undatedShown > 0 && hiddenByAge > 0) {
|
|
46
|
+
parts.push(t('board.lanes.done.undated', { count: undatedShown }))
|
|
47
|
+
}
|
|
48
|
+
return parts.join(' · ')
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div class="space-y-2">
|
|
54
|
+
<!-- The three lanes a reader works in -->
|
|
55
|
+
<div class="flex items-start" :style="{ gap: LANE_GEOMETRY.laneGap + 'px' }">
|
|
56
|
+
<TaskLane
|
|
57
|
+
v-for="rendered in liveLanes"
|
|
58
|
+
:key="rendered.lane"
|
|
59
|
+
:rendered="rendered"
|
|
60
|
+
:group-key="laneView.groupKey"
|
|
61
|
+
:frame-id="frameId"
|
|
62
|
+
:body-height="laneBodyHeight"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- The Done strip. Rendered only once the service HAS finished something: an empty archive
|
|
67
|
+
is the one lane whose emptiness says nothing a reader needs (a new service has merged
|
|
68
|
+
nothing, which its three live lanes already show). -->
|
|
69
|
+
<div v-if="doneLane && doneLane.total > 0" class="rounded-lg bg-slate-900/40">
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
class="nodrag flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left hover:bg-slate-800/40"
|
|
73
|
+
data-testid="done-lane-toggle"
|
|
74
|
+
:aria-expanded="!laneView.doneLaneCollapsed"
|
|
75
|
+
@click.stop="laneView.toggleDoneLane()"
|
|
76
|
+
>
|
|
77
|
+
<UIcon
|
|
78
|
+
:name="laneView.doneLaneCollapsed ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
|
|
79
|
+
class="h-3.5 w-3.5 shrink-0 text-slate-500"
|
|
80
|
+
/>
|
|
81
|
+
<UIcon
|
|
82
|
+
:name="LANE_META.done.icon"
|
|
83
|
+
class="h-3.5 w-3.5 shrink-0"
|
|
84
|
+
:style="{ color: LANE_META.done.color }"
|
|
85
|
+
/>
|
|
86
|
+
<span class="text-[11px] font-semibold text-slate-200">{{
|
|
87
|
+
t(LANE_META.done.labelKey)
|
|
88
|
+
}}</span>
|
|
89
|
+
<!-- The TOTAL, not what the caps admitted: "this service has finished 312 tasks" is the
|
|
90
|
+
fact the lane carries, and counting only the visible cards would understate it by
|
|
91
|
+
two orders of magnitude. -->
|
|
92
|
+
<span
|
|
93
|
+
class="shrink-0 rounded px-1 text-[10px] font-semibold tabular-nums"
|
|
94
|
+
:style="{
|
|
95
|
+
backgroundColor: LANE_META.done.color + '22',
|
|
96
|
+
color: LANE_META.done.color,
|
|
97
|
+
}"
|
|
98
|
+
data-testid="lane-count-done"
|
|
99
|
+
>{{ doneLane.total }}</span
|
|
100
|
+
>
|
|
101
|
+
<span v-if="withheldNote" class="ms-auto truncate text-[10px] text-slate-500">{{
|
|
102
|
+
withheldNote
|
|
103
|
+
}}</span>
|
|
104
|
+
</button>
|
|
105
|
+
|
|
106
|
+
<!-- The archive opens as a WIDE GRID: each group takes the strip's full width and wraps its
|
|
107
|
+
own cards across it, and the groups stack. Wrapping the GROUPS instead read correctly
|
|
108
|
+
only when there were several of them — under the default `none` grouping a lane is
|
|
109
|
+
exactly one group, so the whole archive rendered as a single card-wide column down the
|
|
110
|
+
left of an otherwise empty strip. -->
|
|
111
|
+
<div
|
|
112
|
+
v-if="!laneView.doneLaneCollapsed"
|
|
113
|
+
:data-drop-zone="frameId"
|
|
114
|
+
data-testid="lane-done"
|
|
115
|
+
data-lane="done"
|
|
116
|
+
class="nodrag space-y-2 overflow-y-auto p-2 pt-0"
|
|
117
|
+
:style="{ maxHeight: laneBodyHeight + 'px' }"
|
|
118
|
+
>
|
|
119
|
+
<!-- A zero cap is a real setting ("count them, show none"), so the strip explains why it
|
|
120
|
+
has nothing in it rather than looking broken. -->
|
|
121
|
+
<p
|
|
122
|
+
v-if="doneSelection.shown.length === 0"
|
|
123
|
+
class="px-1 text-[10px] leading-snug text-slate-600"
|
|
124
|
+
>
|
|
125
|
+
{{ t('board.lanes.done.allWithheld') }}
|
|
126
|
+
</p>
|
|
127
|
+
<LaneGroup
|
|
128
|
+
v-for="(group, i) in doneLane.groups"
|
|
129
|
+
v-else
|
|
130
|
+
:key="group.label ?? `catch-all-${i}`"
|
|
131
|
+
:group="group"
|
|
132
|
+
:group-key="laneView.groupKey"
|
|
133
|
+
:frame-id="frameId"
|
|
134
|
+
layout="grid"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|