@cat-factory/app 1.0.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,246 @@
1
+ <script setup lang="ts">
2
+ import type { AgentState, ExecutionInstance } from '~/types/domain'
3
+ import { AGENT_BY_KIND } from '~/utils/catalog'
4
+
5
+ const props = defineProps<{ instance: ExecutionInstance }>()
6
+ const emit = defineEmits<{ openDecision: [decisionId: string] }>()
7
+
8
+ const models = useModelsStore()
9
+
10
+ /** Visual language for an individual agent's runtime state. */
11
+ const STATE_META: Record<AgentState, { label: string; color: string; icon: string }> = {
12
+ pending: { label: 'Pending', color: '#64748b', icon: 'i-lucide-circle-dashed' },
13
+ working: { label: 'Working', color: '#6366f1', icon: 'i-lucide-loader' },
14
+ waiting_decision: { label: 'Needs decision', color: '#f59e0b', icon: 'i-lucide-circle-help' },
15
+ done: { label: 'Done', color: '#22c55e', icon: 'i-lucide-circle-check' },
16
+ }
17
+
18
+ /** Visual language for the pipeline instance as a whole. */
19
+ const STATUS_META: Record<ExecutionInstance['status'], { label: string; chip: string }> = {
20
+ running: { label: 'Running', chip: 'primary' },
21
+ blocked: { label: 'Blocked on decision', chip: 'warning' },
22
+ paused: { label: 'Paused (budget)', chip: 'neutral' },
23
+ done: { label: 'Completed', chip: 'success' },
24
+ failed: { label: 'Failed', chip: 'error' },
25
+ }
26
+
27
+ const steps = computed(() => props.instance.steps)
28
+ const total = computed(() => steps.value.length)
29
+
30
+ /** A step counts as fully complete only once its state is `done`. */
31
+ const completedCount = computed(() => steps.value.filter((s) => s.state === 'done').length)
32
+
33
+ /** Effective 0..1 progress per step (a done step is always 100%). */
34
+ function stepProgress(state: AgentState, progress: number) {
35
+ return state === 'done' ? 1 : progress
36
+ }
37
+
38
+ /** Overall pipeline progress: the mean of every step's effective progress. */
39
+ const overallProgress = computed(() => {
40
+ if (!total.value) return 0
41
+ const sum = steps.value.reduce((acc, s) => acc + stepProgress(s.state, s.progress), 0)
42
+ return sum / total.value
43
+ })
44
+ const overallPct = computed(() => Math.round(overallProgress.value * 100))
45
+
46
+ const statusMeta = computed(() => STATUS_META[props.instance.status])
47
+
48
+ /** The agent the pipeline is currently centred on (for the summary line). */
49
+ const currentAgent = computed(() => {
50
+ const s = steps.value[props.instance.currentStep]
51
+ return s ? AGENT_BY_KIND[s.agentKind].label : null
52
+ })
53
+
54
+ /** Connector below a step is "lit" once that step has completed. */
55
+ function connectorDone(index: number) {
56
+ return steps.value[index]?.state === 'done'
57
+ }
58
+
59
+ const legend: { state: AgentState }[] = [
60
+ { state: 'done' },
61
+ { state: 'working' },
62
+ { state: 'waiting_decision' },
63
+ { state: 'pending' },
64
+ ]
65
+ </script>
66
+
67
+ <template>
68
+ <div class="flex flex-col gap-5">
69
+ <!-- summary -->
70
+ <div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
71
+ <div class="flex flex-wrap items-center gap-3">
72
+ <UBadge :color="statusMeta.chip as any" variant="subtle">{{ statusMeta.label }}</UBadge>
73
+ <span class="text-sm text-slate-300">
74
+ <span class="font-semibold text-white">{{ completedCount }}</span>
75
+ / {{ total }} agents complete
76
+ </span>
77
+ <span v-if="currentAgent && instance.status === 'running'" class="text-xs text-slate-500">
78
+ · currently {{ currentAgent }}
79
+ </span>
80
+ <span class="ml-auto font-mono text-sm tabular-nums text-slate-200">{{ overallPct }}%</span>
81
+ </div>
82
+ <UProgress :model-value="overallPct" class="mt-3" />
83
+
84
+ <!-- legend -->
85
+ <div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
86
+ <span
87
+ v-for="l in legend"
88
+ :key="l.state"
89
+ class="inline-flex items-center gap-1.5 text-[11px] text-slate-400"
90
+ >
91
+ <span
92
+ class="h-2 w-2 rounded-full"
93
+ :style="{ backgroundColor: STATE_META[l.state].color }"
94
+ />
95
+ {{ STATE_META[l.state].label }}
96
+ </span>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- agent chain as a vertical timeline -->
101
+ <ol class="flex flex-col">
102
+ <li v-for="(s, i) in steps" :key="i" class="relative flex gap-4 pb-5 last:pb-0">
103
+ <!-- connector line to the next step -->
104
+ <span
105
+ v-if="i < steps.length - 1"
106
+ class="absolute top-9 bottom-0 left-[17px] w-0.5 -translate-x-1/2"
107
+ :class="connectorDone(i) ? 'bg-emerald-500/60' : 'bg-slate-700'"
108
+ />
109
+
110
+ <!-- rail node -->
111
+ <span
112
+ class="relative z-10 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 bg-slate-950"
113
+ :class="s.state === 'working' ? 'step-active' : ''"
114
+ :style="{ borderColor: STATE_META[s.state].color }"
115
+ >
116
+ <UIcon
117
+ :name="STATE_META[s.state].icon"
118
+ class="h-4 w-4"
119
+ :class="s.state === 'working' ? 'animate-spin' : ''"
120
+ :style="{ color: STATE_META[s.state].color }"
121
+ />
122
+ </span>
123
+
124
+ <!-- step content card -->
125
+ <div
126
+ class="flex-1 rounded-xl border p-4 transition"
127
+ :class="[
128
+ i === instance.currentStep && instance.status !== 'done'
129
+ ? 'border-indigo-500/70 bg-slate-900 shadow-lg shadow-indigo-500/10'
130
+ : 'border-slate-800 bg-slate-900/50',
131
+ s.state === 'pending' ? 'opacity-60' : '',
132
+ ]"
133
+ >
134
+ <div class="flex items-center gap-2">
135
+ <div
136
+ class="flex h-8 w-8 items-center justify-center rounded-lg"
137
+ :style="{ backgroundColor: AGENT_BY_KIND[s.agentKind].color + '22' }"
138
+ >
139
+ <UIcon
140
+ :name="AGENT_BY_KIND[s.agentKind].icon"
141
+ class="h-4 w-4"
142
+ :style="{ color: AGENT_BY_KIND[s.agentKind].color }"
143
+ />
144
+ </div>
145
+ <div class="min-w-0">
146
+ <div class="truncate text-sm font-semibold text-white">
147
+ {{ AGENT_BY_KIND[s.agentKind].label }}
148
+ </div>
149
+ <div class="text-[10px] uppercase tracking-wide text-slate-500">
150
+ Step {{ i + 1 }} of {{ total }}
151
+ </div>
152
+ </div>
153
+ <span
154
+ class="ml-auto shrink-0 text-[11px] font-medium"
155
+ :style="{ color: STATE_META[s.state].color }"
156
+ >
157
+ {{ STATE_META[s.state].label }}
158
+ </span>
159
+ </div>
160
+
161
+ <!-- per-step progress (only while it has meaningful progress) -->
162
+ <UProgress
163
+ v-if="s.state === 'working' || s.state === 'done'"
164
+ :model-value="Math.round(stepProgress(s.state, s.progress) * 100)"
165
+ size="xs"
166
+ class="mt-3"
167
+ />
168
+
169
+ <!-- live subtask counts from the agent's todo list -->
170
+ <div v-if="s.subtasks && s.subtasks.total > 0" class="mt-2">
171
+ <div class="flex items-center justify-between text-[10px] text-slate-400">
172
+ <span>
173
+ {{ s.subtasks.completed }}/{{ s.subtasks.total }} subtasks
174
+ <span v-if="s.subtasks.inProgress > 0" class="text-indigo-300">
175
+ · {{ s.subtasks.inProgress }} in progress
176
+ </span>
177
+ </span>
178
+ </div>
179
+ <div class="mt-1 h-1 overflow-hidden rounded-full bg-slate-700/60">
180
+ <div
181
+ class="h-full rounded-full bg-indigo-400 transition-all duration-500"
182
+ :style="{ width: `${(s.subtasks.completed / s.subtasks.total) * 100}%` }"
183
+ />
184
+ </div>
185
+ </div>
186
+
187
+ <!-- model used for this step -->
188
+ <p
189
+ v-if="s.model"
190
+ class="mt-2 flex items-center gap-1 truncate text-[10px] text-slate-500"
191
+ :title="s.model"
192
+ >
193
+ <UIcon name="i-lucide-cpu" class="h-3 w-3 shrink-0" />
194
+ {{ models.labelForRef(s.model) }}
195
+ </p>
196
+
197
+ <!-- output the agent produced -->
198
+ <p
199
+ v-if="s.output"
200
+ class="mt-2 line-clamp-3 rounded-md bg-slate-950/60 px-2 py-1.5 text-[11px] text-slate-300"
201
+ :title="s.output"
202
+ >
203
+ {{ s.output }}
204
+ </p>
205
+
206
+ <!-- decision: unresolved => prompt, resolved => show the choice -->
207
+ <div v-if="s.decision && !s.decision.chosen" class="mt-3">
208
+ <UButton
209
+ color="warning"
210
+ variant="soft"
211
+ size="xs"
212
+ icon="i-lucide-circle-help"
213
+ @click="emit('openDecision', s.decision.id)"
214
+ >
215
+ Resolve: {{ s.decision.question }}
216
+ </UButton>
217
+ </div>
218
+ <p
219
+ v-else-if="s.decision?.chosen"
220
+ class="mt-2 flex items-center gap-1 truncate text-[11px] text-emerald-400"
221
+ :title="s.decision.chosen"
222
+ >
223
+ <UIcon name="i-lucide-check" class="h-3 w-3 shrink-0" />
224
+ {{ s.decision.chosen }}
225
+ </p>
226
+ </div>
227
+ </li>
228
+ </ol>
229
+ </div>
230
+ </template>
231
+
232
+ <style scoped>
233
+ /* Soft indigo halo around the rail node of the actively-working step. */
234
+ @keyframes step-pulse {
235
+ 0%,
236
+ 100% {
237
+ box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5);
238
+ }
239
+ 50% {
240
+ box-shadow: 0 0 0 6px rgba(99, 102, 241, 0);
241
+ }
242
+ }
243
+ .step-active {
244
+ animation: step-pulse 1.6s ease-in-out infinite;
245
+ }
246
+ </style>
@@ -0,0 +1,328 @@
1
+ <script setup lang="ts">
2
+ // Requirements-review panel: surfaces the stateless reviewer agent's questions /
3
+ // gaps / clarifications about a block's collected requirements in a form a human
4
+ // can work through — answer or dismiss each item, then incorporate the answers
5
+ // back into the block's requirements. Triggered from the inspector; the block id
6
+ // to review lives on the ui store.
7
+ import type {
8
+ RequirementReview,
9
+ RequirementReviewItem,
10
+ ReviewItemCategory,
11
+ ReviewItemSeverity,
12
+ ReviewItemStatus,
13
+ } from '~/types/requirements'
14
+
15
+ const ui = useUiStore()
16
+ const board = useBoardStore()
17
+ const requirements = useRequirementsStore()
18
+ const toast = useToast()
19
+
20
+ const open = computed({
21
+ get: () => ui.requirementReviewBlockId !== null,
22
+ set: (v: boolean) => {
23
+ if (!v) ui.closeRequirementReview()
24
+ },
25
+ })
26
+
27
+ const blockId = computed(() => ui.requirementReviewBlockId)
28
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
29
+ const review = computed<RequirementReview | null>(() =>
30
+ blockId.value ? requirements.reviewFor(blockId.value) : null,
31
+ )
32
+ const busy = computed(() => (blockId.value ? requirements.isReviewing(blockId.value) : false))
33
+ const incorporating = computed(() =>
34
+ review.value ? requirements.isIncorporating(review.value.id) : false,
35
+ )
36
+
37
+ // Draft replies, keyed by item id, so editing one item doesn't disturb others.
38
+ const drafts = ref<Record<string, string>>({})
39
+
40
+ // Load the current review whenever the panel opens for a block.
41
+ watch(blockId, (id) => {
42
+ drafts.value = {}
43
+ if (id) void requirements.load(id)
44
+ })
45
+
46
+ const sortedItems = computed<RequirementReviewItem[]>(() => {
47
+ if (!review.value) return []
48
+ const rank: Record<ReviewItemSeverity, number> = { high: 0, medium: 1, low: 2 }
49
+ return [...review.value.items].sort((a, b) => rank[a.severity] - rank[b.severity])
50
+ })
51
+
52
+ const openCount = computed(() => (review.value ? requirements.openCount(review.value) : 0))
53
+ const canIncorporate = computed(() => !!review.value && requirements.allSettled(review.value))
54
+
55
+ const SEVERITY_COLOR: Record<ReviewItemSeverity, string> = {
56
+ high: 'error',
57
+ medium: 'warning',
58
+ low: 'neutral',
59
+ }
60
+ const CATEGORY_ICON: Record<ReviewItemCategory, string> = {
61
+ gap: 'i-lucide-puzzle',
62
+ clarification: 'i-lucide-help-circle',
63
+ assumption: 'i-lucide-lightbulb',
64
+ risk: 'i-lucide-shield-alert',
65
+ question: 'i-lucide-message-circle-question',
66
+ }
67
+ const STATUS_COLOR: Record<ReviewItemStatus, string> = {
68
+ open: 'warning',
69
+ answered: 'info',
70
+ resolved: 'success',
71
+ dismissed: 'neutral',
72
+ }
73
+
74
+ function notifyError(title: string, e: unknown) {
75
+ toast.add({
76
+ title,
77
+ description: e instanceof Error ? e.message : String(e),
78
+ icon: 'i-lucide-triangle-alert',
79
+ color: 'error',
80
+ })
81
+ }
82
+
83
+ async function runReview() {
84
+ if (!blockId.value) return
85
+ try {
86
+ const result = await requirements.review(blockId.value)
87
+ toast.add({
88
+ title: result.items.length
89
+ ? `${result.items.length} item(s) to review`
90
+ : 'No gaps found — requirements look complete',
91
+ icon: 'i-lucide-sparkles',
92
+ })
93
+ } catch (e) {
94
+ notifyError('Could not run the requirements review', e)
95
+ }
96
+ }
97
+
98
+ async function submitReply(item: RequirementReviewItem) {
99
+ if (!review.value) return
100
+ const text = (drafts.value[item.id] ?? '').trim()
101
+ if (!text) return
102
+ try {
103
+ await requirements.reply(review.value, item.id, text)
104
+ drafts.value = { ...drafts.value, [item.id]: '' }
105
+ toast.add({ title: 'Answer saved', icon: 'i-lucide-check' })
106
+ } catch (e) {
107
+ notifyError('Could not save the answer', e)
108
+ }
109
+ }
110
+
111
+ async function setStatus(item: RequirementReviewItem, status: ReviewItemStatus) {
112
+ if (!review.value) return
113
+ try {
114
+ await requirements.setItemStatus(review.value, item.id, status)
115
+ } catch (e) {
116
+ notifyError('Could not update the item', e)
117
+ }
118
+ }
119
+
120
+ async function incorporate() {
121
+ if (!review.value) return
122
+ try {
123
+ await requirements.incorporate(review.value)
124
+ toast.add({ title: 'Answers incorporated into the requirements', icon: 'i-lucide-check-check' })
125
+ } catch (e) {
126
+ notifyError('Could not incorporate the answers', e)
127
+ }
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <UModal
133
+ v-model:open="open"
134
+ title="Requirements review"
135
+ :description="block?.title"
136
+ :ui="{ content: 'max-w-2xl' }"
137
+ >
138
+ <template #body>
139
+ <div class="flex flex-col gap-4">
140
+ <p class="text-sm text-slate-400">
141
+ An AI reviewer inspects this {{ block?.level ?? 'item' }}’s collected requirements — its
142
+ description plus any linked PRDs and tracker issues — and raises gaps, ambiguities and
143
+ questions. Answer or dismiss each, then incorporate the answers back into the
144
+ requirements.
145
+ </p>
146
+
147
+ <!-- run / re-run -->
148
+ <div class="flex items-center gap-2">
149
+ <UButton
150
+ color="primary"
151
+ variant="solid"
152
+ size="sm"
153
+ icon="i-lucide-sparkles"
154
+ :loading="busy"
155
+ @click="runReview"
156
+ >
157
+ {{ review ? 'Re-run review' : 'Run review' }}
158
+ </UButton>
159
+ <span v-if="review" class="text-xs text-slate-500">
160
+ {{ review.items.length }} item(s) · {{ openCount }} open
161
+ <template v-if="review.model"> · {{ review.model }}</template>
162
+ </span>
163
+ </div>
164
+
165
+ <!-- empty / first-run state -->
166
+ <div
167
+ v-if="!review && !busy"
168
+ class="rounded-lg border border-dashed border-slate-700 p-6 text-center text-sm text-slate-500"
169
+ >
170
+ No review yet. Run the reviewer to surface open questions about the requirements.
171
+ </div>
172
+
173
+ <!-- working state -->
174
+ <div
175
+ v-else-if="busy && !review"
176
+ class="flex items-center justify-center gap-2 p-6 text-sm text-slate-400"
177
+ >
178
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
179
+ Reviewing the requirements…
180
+ </div>
181
+
182
+ <!-- no items: requirements look complete -->
183
+ <div
184
+ v-else-if="review && review.items.length === 0"
185
+ class="flex items-center gap-2 rounded-lg border border-emerald-900/60 bg-emerald-950/30 p-4 text-sm text-emerald-300"
186
+ >
187
+ <UIcon name="i-lucide-circle-check" class="h-5 w-5" />
188
+ The reviewer found no gaps — the requirements look complete and unambiguous.
189
+ </div>
190
+
191
+ <!-- items -->
192
+ <div v-else-if="review" class="flex flex-col gap-3">
193
+ <div
194
+ v-for="item in sortedItems"
195
+ :key="item.id"
196
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
197
+ :class="{ 'opacity-60': item.status === 'dismissed' }"
198
+ >
199
+ <div class="flex items-start gap-2">
200
+ <UIcon
201
+ :name="CATEGORY_ICON[item.category]"
202
+ class="mt-0.5 h-4 w-4 shrink-0 text-slate-400"
203
+ />
204
+ <div class="min-w-0 flex-1">
205
+ <div class="flex flex-wrap items-center gap-1.5">
206
+ <span class="text-sm font-medium text-white">{{ item.title }}</span>
207
+ <UBadge size="xs" variant="subtle" :color="SEVERITY_COLOR[item.severity] as any">
208
+ {{ item.severity }}
209
+ </UBadge>
210
+ <UBadge size="xs" variant="outline" color="neutral">{{ item.category }}</UBadge>
211
+ <UBadge
212
+ size="xs"
213
+ variant="soft"
214
+ :color="STATUS_COLOR[item.status] as any"
215
+ class="ml-auto"
216
+ >
217
+ {{ item.status }}
218
+ </UBadge>
219
+ </div>
220
+ <p class="mt-1 whitespace-pre-line text-sm text-slate-400">{{ item.detail }}</p>
221
+
222
+ <!-- recorded answer -->
223
+ <div
224
+ v-if="item.reply"
225
+ class="mt-2 rounded-md border-l-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
226
+ >
227
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">Answer</span>
228
+ <p class="whitespace-pre-line">{{ item.reply }}</p>
229
+ </div>
230
+
231
+ <!-- reply + actions (hidden once settled) -->
232
+ <template v-if="item.status !== 'resolved' && item.status !== 'dismissed'">
233
+ <UTextarea
234
+ v-model="drafts[item.id]"
235
+ :rows="2"
236
+ autoresize
237
+ size="sm"
238
+ class="mt-2 w-full"
239
+ :placeholder="item.reply ? 'Refine your answer…' : 'Answer this question…'"
240
+ />
241
+ <div class="mt-2 flex flex-wrap items-center gap-2">
242
+ <UButton
243
+ color="primary"
244
+ variant="soft"
245
+ size="xs"
246
+ icon="i-lucide-corner-down-left"
247
+ :disabled="!(drafts[item.id] ?? '').trim()"
248
+ @click="submitReply(item)"
249
+ >
250
+ Save answer
251
+ </UButton>
252
+ <UButton
253
+ color="success"
254
+ variant="ghost"
255
+ size="xs"
256
+ icon="i-lucide-check"
257
+ @click="setStatus(item, 'resolved')"
258
+ >
259
+ Resolve
260
+ </UButton>
261
+ <UButton
262
+ color="neutral"
263
+ variant="ghost"
264
+ size="xs"
265
+ icon="i-lucide-x"
266
+ @click="setStatus(item, 'dismissed')"
267
+ >
268
+ Dismiss
269
+ </UButton>
270
+ </div>
271
+ </template>
272
+
273
+ <!-- reopen a settled item -->
274
+ <div v-else class="mt-2">
275
+ <UButton
276
+ color="neutral"
277
+ variant="ghost"
278
+ size="xs"
279
+ icon="i-lucide-rotate-ccw"
280
+ @click="setStatus(item, item.reply ? 'answered' : 'open')"
281
+ >
282
+ Reopen
283
+ </UButton>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- incorporate -->
290
+ <div class="mt-1 flex items-center gap-2 border-t border-slate-800 pt-3">
291
+ <UButton
292
+ color="primary"
293
+ size="sm"
294
+ icon="i-lucide-check-check"
295
+ :loading="incorporating"
296
+ :disabled="!canIncorporate"
297
+ @click="incorporate"
298
+ >
299
+ Incorporate answers
300
+ </UButton>
301
+ <span class="text-xs text-slate-500">
302
+ <template v-if="review.status === 'incorporated'">
303
+ Answers folded into the requirements.
304
+ </template>
305
+ <template v-else-if="canIncorporate">
306
+ All items settled — fold the answers into the requirements.
307
+ </template>
308
+ <template v-else> Resolve or dismiss all items to enable. </template>
309
+ </span>
310
+ </div>
311
+
312
+ <!-- incorporated result -->
313
+ <div
314
+ v-if="review.incorporatedRequirements"
315
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
316
+ >
317
+ <div class="mb-1 text-[10px] uppercase tracking-wide text-slate-500">
318
+ Updated requirements
319
+ </div>
320
+ <p class="whitespace-pre-line text-sm text-slate-300">
321
+ {{ review.incorporatedRequirements }}
322
+ </p>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </template>
327
+ </UModal>
328
+ </template>