@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.
@@ -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), uploads reference
10
- * design images, and resolves stored artifacts into object URLs for the gallery. A per-block
11
- * `busy` flag lets the window disable its controls while an action is in flight.
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
- /** Resolve a stored artifact to an object URL (cached). Returns null on failure. */
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
  })
@@ -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
@@ -28,6 +28,7 @@ const AGENT_KINDS: AgentKind[] = [
28
28
  'integrator',
29
29
  'architect-companion',
30
30
  'spec-companion',
31
+ 'doc-reviewer',
31
32
  'playwright',
32
33
  'mocker',
33
34
  'business-documenter',
@@ -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.43.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.41.0"
37
+ "@cat-factory/contracts": "0.42.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.1",