@cat-factory/app 0.43.0 → 0.45.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/app/components/board/AddTaskModal.vue +57 -18
- package/app/components/media/ArtifactLightbox.vue +273 -0
- package/app/components/media/ImageCompare.vue +305 -0
- package/app/components/testing/TestReportWindow.vue +149 -8
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +190 -104
- package/app/composables/useArtifactBlobs.ts +120 -0
- package/app/composables/useFocusTrap.ts +72 -0
- package/app/stores/visualConfirm.ts +6 -35
- package/app/types/domain.ts +6 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +9 -0
- package/package.json +2 -2
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-component cache for resolving stored binary artifacts (screenshots / reference
|
|
6
|
+
* designs) into `<img>`-ready object URLs.
|
|
7
|
+
*
|
|
8
|
+
* The artifact bytes are served behind an authed endpoint (`GET /workspaces/:ws/
|
|
9
|
+
* artifacts/:id/blob`), so the browser can't point an `<img src>` straight at them — they
|
|
10
|
+
* have to be fetched as a `Blob` and turned into an `URL.createObjectURL`. That object URL
|
|
11
|
+
* pins the blob in memory until it's explicitly revoked, so this composable is a FACTORY
|
|
12
|
+
* (one cache per calling component), and the caller MUST `revokeAll()` on unmount. Making
|
|
13
|
+
* it a global singleton would mean one window's unmount frees another window's images.
|
|
14
|
+
*
|
|
15
|
+
* Both the visual-confirmation gate and the test-report window use this, so neither has to
|
|
16
|
+
* own blob plumbing or depend on the other's Pinia store.
|
|
17
|
+
*/
|
|
18
|
+
export type ArtifactBlobStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
19
|
+
|
|
20
|
+
export function useArtifactBlobs() {
|
|
21
|
+
const ws = useWorkspaceStore()
|
|
22
|
+
const api = useApi()
|
|
23
|
+
|
|
24
|
+
/** artifactId → object URL (reactive so templates re-render when a blob resolves). */
|
|
25
|
+
const urls = reactive<Record<string, string>>({})
|
|
26
|
+
/** artifactId → fetch status, drives loading / error / retry affordances. */
|
|
27
|
+
const status = reactive<Record<string, ArtifactBlobStatus>>({})
|
|
28
|
+
/** In-flight promises, so concurrent `resolve(id)` calls share one fetch + one blob. */
|
|
29
|
+
const inFlight = new Map<string, Promise<string | null>>()
|
|
30
|
+
/**
|
|
31
|
+
* Set once `revokeAll()` has run (the owning component unmounted). A fetch already in
|
|
32
|
+
* flight at that point still creates its object URL when it settles; without this guard
|
|
33
|
+
* that URL would be written into the now-cleared cache and never revoked (a leak), and
|
|
34
|
+
* we'd be mutating reactive state for a dead component.
|
|
35
|
+
*/
|
|
36
|
+
let disposed = false
|
|
37
|
+
|
|
38
|
+
function urlFor(id: string | null | undefined): string | undefined {
|
|
39
|
+
return id ? urls[id] : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusFor(id: string | null | undefined): ArtifactBlobStatus {
|
|
43
|
+
return id ? (status[id] ?? 'idle') : 'idle'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve an artifact to an object URL (cached + deduped). Returns null on failure. */
|
|
47
|
+
function resolve(id: string | null | undefined): Promise<string | null> {
|
|
48
|
+
if (!id || disposed) return Promise.resolve(null)
|
|
49
|
+
const cached = urls[id]
|
|
50
|
+
if (cached) return Promise.resolve(cached)
|
|
51
|
+
const pending = inFlight.get(id)
|
|
52
|
+
if (pending) return pending
|
|
53
|
+
|
|
54
|
+
status[id] = 'loading'
|
|
55
|
+
const p = api
|
|
56
|
+
.fetchArtifactBlobUrl(ws.requireId(), id)
|
|
57
|
+
.then((url) => {
|
|
58
|
+
// The owner unmounted while this was in flight: revoke the freshly-minted URL
|
|
59
|
+
// instead of stranding it in the cleared cache.
|
|
60
|
+
if (disposed) {
|
|
61
|
+
try {
|
|
62
|
+
URL.revokeObjectURL(url)
|
|
63
|
+
} catch {
|
|
64
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
urls[id] = url
|
|
69
|
+
status[id] = 'ready'
|
|
70
|
+
return url
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
status[id] = 'error'
|
|
74
|
+
return null
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
inFlight.delete(id)
|
|
78
|
+
})
|
|
79
|
+
inFlight.set(id, p)
|
|
80
|
+
return p
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Force a re-fetch of a previously-failed artifact (clears its cached error state). */
|
|
84
|
+
function retry(id: string): Promise<string | null> {
|
|
85
|
+
const stale = urls[id]
|
|
86
|
+
if (stale) {
|
|
87
|
+
try {
|
|
88
|
+
URL.revokeObjectURL(stale)
|
|
89
|
+
} catch {
|
|
90
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
delete urls[id]
|
|
94
|
+
status[id] = 'idle'
|
|
95
|
+
inFlight.delete(id)
|
|
96
|
+
return resolve(id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Revoke every cached object URL and clear the cache. Call on `onUnmounted` — otherwise
|
|
101
|
+
* the (potentially large) screenshot bytes linger in memory for the session's lifetime.
|
|
102
|
+
*/
|
|
103
|
+
function revokeAll(): void {
|
|
104
|
+
disposed = true
|
|
105
|
+
for (const url of Object.values(urls)) {
|
|
106
|
+
try {
|
|
107
|
+
URL.revokeObjectURL(url)
|
|
108
|
+
} catch {
|
|
109
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const k of Object.keys(urls)) delete urls[k]
|
|
113
|
+
for (const k of Object.keys(status)) delete status[k]
|
|
114
|
+
inFlight.clear()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { urls, status, urlFor, statusFor, resolve, retry, revokeAll }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ArtifactBlobs = ReturnType<typeof useArtifactBlobs>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { nextTick, onScopeDispose, watch, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight focus management for a modal surface (the screenshot review windows + the
|
|
5
|
+
* shared lightbox). While `active`, it:
|
|
6
|
+
* · moves focus into the container on open (so keyboard / screen-reader users land inside
|
|
7
|
+
* the dialog instead of staying on the background),
|
|
8
|
+
* · traps Tab / Shift+Tab within the container's focusable elements, and
|
|
9
|
+
* · restores focus to whatever was focused before, on close.
|
|
10
|
+
*
|
|
11
|
+
* Nested surfaces (a lightbox opened over a review window) hand off cleanly because each
|
|
12
|
+
* caller scopes its own `active` — the window passes `open && !lightboxOpen`, so exactly one
|
|
13
|
+
* trap is live at a time and they never fight over Tab.
|
|
14
|
+
*/
|
|
15
|
+
export function useFocusTrap(container: Ref<HTMLElement | null>, active: Ref<boolean>): void {
|
|
16
|
+
let previouslyFocused: HTMLElement | null = null
|
|
17
|
+
|
|
18
|
+
function focusables(): HTMLElement[] {
|
|
19
|
+
const root = container.value
|
|
20
|
+
if (!root) return []
|
|
21
|
+
const nodes = root.querySelectorAll<HTMLElement>(
|
|
22
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
23
|
+
)
|
|
24
|
+
// Skip elements that aren't actually rendered (e.g. inside a `v-if`/`hidden` branch).
|
|
25
|
+
return Array.from(nodes).filter(
|
|
26
|
+
(el) => el.offsetParent !== null || el === document.activeElement,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
31
|
+
if (!active.value || e.key !== 'Tab') return
|
|
32
|
+
const els = focusables()
|
|
33
|
+
if (!els.length) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
container.value?.focus()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const first = els[0]!
|
|
39
|
+
const last = els[els.length - 1]!
|
|
40
|
+
const current = document.activeElement as HTMLElement | null
|
|
41
|
+
const inside = !!container.value?.contains(current)
|
|
42
|
+
if (e.shiftKey) {
|
|
43
|
+
if (!inside || current === first) {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
last.focus()
|
|
46
|
+
}
|
|
47
|
+
} else if (!inside || current === last) {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
first.focus()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
watch(
|
|
54
|
+
active,
|
|
55
|
+
(on) => {
|
|
56
|
+
if (on) {
|
|
57
|
+
previouslyFocused = document.activeElement as HTMLElement | null
|
|
58
|
+
window.addEventListener('keydown', onKeydown, true)
|
|
59
|
+
void nextTick(() => {
|
|
60
|
+
;(focusables()[0] ?? container.value)?.focus()
|
|
61
|
+
})
|
|
62
|
+
} else {
|
|
63
|
+
window.removeEventListener('keydown', onKeydown, true)
|
|
64
|
+
previouslyFocused?.focus?.()
|
|
65
|
+
previouslyFocused = null
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ immediate: true },
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
onScopeDispose(() => window.removeEventListener('keydown', onKeydown, true))
|
|
72
|
+
}
|
|
@@ -6,9 +6,11 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
6
6
|
/**
|
|
7
7
|
* Visual-confirmation gate actions. The gate's live state rides on its execution step
|
|
8
8
|
* (`step.visualConfirm`) and arrives via the execution stream, so this store holds NO gate
|
|
9
|
-
* state — it only drives the actions (approve / request a fix / recapture)
|
|
10
|
-
* design images
|
|
11
|
-
*
|
|
9
|
+
* state — it only drives the actions (approve / request a fix / recapture) and uploads
|
|
10
|
+
* reference design images. A per-block `busy` flag lets the window disable its controls
|
|
11
|
+
* while an action is in flight. (Resolving stored artifacts into object URLs for the gallery
|
|
12
|
+
* lives in the per-component `useArtifactBlobs` composable, so each window owns + revokes its
|
|
13
|
+
* own blob cache on unmount.)
|
|
12
14
|
*/
|
|
13
15
|
export const useVisualConfirmStore = defineStore('visualConfirm', () => {
|
|
14
16
|
const api = useApi()
|
|
@@ -16,8 +18,6 @@ export const useVisualConfirmStore = defineStore('visualConfirm', () => {
|
|
|
16
18
|
const execution = useExecutionStore()
|
|
17
19
|
|
|
18
20
|
const busy = ref<Set<string>>(new Set())
|
|
19
|
-
/** Cache of artifactId → object URL, so the gallery doesn't re-fetch the same blob. */
|
|
20
|
-
const blobUrls = ref<Map<string, string>>(new Map())
|
|
21
21
|
|
|
22
22
|
function isBusy(blockId: string): boolean {
|
|
23
23
|
return busy.value.has(blockId)
|
|
@@ -59,34 +59,5 @@ export const useVisualConfirmStore = defineStore('visualConfirm', () => {
|
|
|
59
59
|
return run(blockId, () => api.uploadReferenceArtifact(ws.requireId(), blockId, file, view))
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
async function blobUrl(artifactId: string): Promise<string | null> {
|
|
64
|
-
const cached = blobUrls.value.get(artifactId)
|
|
65
|
-
if (cached) return cached
|
|
66
|
-
try {
|
|
67
|
-
const url = await api.fetchArtifactBlobUrl(ws.requireId(), artifactId)
|
|
68
|
-
blobUrls.value.set(artifactId, url)
|
|
69
|
-
return url
|
|
70
|
-
} catch {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Release every cached object URL and clear the cache. `URL.createObjectURL` holds the
|
|
77
|
-
* blob in memory until explicitly revoked, so the gate window calls this on unmount to
|
|
78
|
-
* avoid leaking the (potentially large) screenshot bytes for the session's lifetime.
|
|
79
|
-
*/
|
|
80
|
-
function revokeBlobs(): void {
|
|
81
|
-
for (const url of blobUrls.value.values()) {
|
|
82
|
-
try {
|
|
83
|
-
URL.revokeObjectURL(url)
|
|
84
|
-
} catch {
|
|
85
|
-
// Ignore — a URL already revoked / unsupported environment.
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
blobUrls.value = new Map()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return { isBusy, approve, requestFix, recapture, uploadReference, blobUrl, revokeBlobs }
|
|
62
|
+
return { isBusy, approve, requestFix, recapture, uploadReference }
|
|
92
63
|
})
|
package/app/types/domain.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type {
|
|
|
22
22
|
TaskType,
|
|
23
23
|
CreateTaskType,
|
|
24
24
|
TaskTypeFields,
|
|
25
|
+
DocKind,
|
|
25
26
|
Block,
|
|
26
27
|
PullRequestRef,
|
|
27
28
|
CloudProvider,
|
|
@@ -32,6 +33,7 @@ export type {
|
|
|
32
33
|
TestConcern,
|
|
33
34
|
TestOutcome,
|
|
34
35
|
TestReport,
|
|
36
|
+
TestScreenshot,
|
|
35
37
|
AgentKind,
|
|
36
38
|
AgentCategory,
|
|
37
39
|
CustomAgentKind,
|
|
@@ -52,6 +54,10 @@ export type {
|
|
|
52
54
|
|
|
53
55
|
import type { AgentCategory, AgentKind } from '@cat-factory/contracts'
|
|
54
56
|
|
|
57
|
+
// The document-kind list is a runtime value (used to render the picker), so it is re-exported
|
|
58
|
+
// as a value — the single source of truth lives in the contracts package.
|
|
59
|
+
export { DOC_KINDS } from '@cat-factory/contracts'
|
|
60
|
+
|
|
55
61
|
/** A draggable agent definition shown in the agent palette. Frontend-only. */
|
|
56
62
|
export interface AgentArchetype {
|
|
57
63
|
kind: AgentKind
|
package/app/utils/catalog.ts
CHANGED
|
@@ -251,6 +251,14 @@ export const COMPANION_ARCHETYPES: AgentArchetype[] = [
|
|
|
251
251
|
description:
|
|
252
252
|
'Reviews the spec — especially acceptance-scenario coverage — rating it and looping the Spec Writer back for automatic rework below the threshold, instead of requiring a human review.',
|
|
253
253
|
},
|
|
254
|
+
{
|
|
255
|
+
kind: 'doc-reviewer',
|
|
256
|
+
label: 'Doc Reviewer',
|
|
257
|
+
icon: 'i-lucide-file-search',
|
|
258
|
+
color: '#818cf8',
|
|
259
|
+
description:
|
|
260
|
+
'Reviews the drafted document for completeness, clarity, accuracy and structure, looping the Doc Writer back for automatic rework below the threshold.',
|
|
261
|
+
},
|
|
254
262
|
]
|
|
255
263
|
|
|
256
264
|
/**
|
|
@@ -262,6 +270,7 @@ export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
|
|
|
262
270
|
coder: 'reviewer',
|
|
263
271
|
architect: 'architect-companion',
|
|
264
272
|
'spec-writer': 'spec-companion',
|
|
273
|
+
'doc-writer': 'doc-reviewer',
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
const COMPANION_KINDS: ReadonlySet<string> = new Set(COMPANION_ARCHETYPES.map((a) => a.kind))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
35
35
|
"vue": "^3.5.38",
|
|
36
36
|
"wretch": "^3.0.9",
|
|
37
|
-
"@cat-factory/contracts": "0.
|
|
37
|
+
"@cat-factory/contracts": "0.42.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@toad-contracts/testing": "0.3.1",
|