@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,132 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3
+ import { useRafFn } from '@vueuse/core'
4
+
5
+ /**
6
+ * Draws dependency arrows between task cards as an SVG overlay on top of the
7
+ * board. Tasks are plain DOM nodes (inside frame cards), so we resolve their
8
+ * on-screen rectangles by `[data-block-id]` every frame — this makes arrows
9
+ * follow pan / zoom / drag / expand for free. When a task's frame is collapsed
10
+ * (its card isn't rendered), the arrow anchors to the frame card instead.
11
+ */
12
+ const board = useBoardStore()
13
+
14
+ const svg = ref<SVGSVGElement | null>(null)
15
+
16
+ type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
17
+ const segments = ref<Seg[]>([])
18
+
19
+ // task → its dependencies, both ends being tasks
20
+ const taskDeps = computed(() => {
21
+ const out: { id: string; source: string; target: string }[] = []
22
+ for (const t of board.allTasks) {
23
+ for (const depId of t.dependsOn) {
24
+ const dep = board.getBlock(depId)
25
+ if (dep && dep.level === 'task')
26
+ out.push({ id: `${depId}__${t.id}`, source: depId, target: t.id })
27
+ }
28
+ }
29
+ return out
30
+ })
31
+
32
+ /** Resolve a task's anchor: walk up task → module → service to the first card
33
+ * that's actually rendered (a container may be collapsed). */
34
+ function anchorEl(taskId: string): HTMLElement | null {
35
+ let cur = board.getBlock(taskId)
36
+ while (cur) {
37
+ const el = document.querySelector(`[data-block-id="${cur.id}"]`) as HTMLElement | null
38
+ if (el) return el
39
+ cur = cur.parentId ? board.getBlock(cur.parentId) : undefined
40
+ }
41
+ return null
42
+ }
43
+
44
+ /** Point on a rect's border along the direction toward (tx,ty). */
45
+ function border(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
46
+ const dx = tx - cx
47
+ const dy = ty - cy
48
+ if (dx === 0 && dy === 0) return { x: cx, y: cy }
49
+ const t = Math.min(dx ? hw / Math.abs(dx) : Infinity, dy ? hh / Math.abs(dy) : Infinity)
50
+ return { x: cx + dx * t, y: cy + dy * t }
51
+ }
52
+
53
+ function recompute() {
54
+ const el = svg.value
55
+ if (!el) return
56
+ const origin = el.getBoundingClientRect()
57
+ const next: Seg[] = []
58
+
59
+ for (const d of taskDeps.value) {
60
+ const a = anchorEl(d.source)
61
+ const b = anchorEl(d.target)
62
+ if (!a || !b || a === b) continue // missing, or both collapsed into the same frame
63
+
64
+ const ra = a.getBoundingClientRect()
65
+ const rb = b.getBoundingClientRect()
66
+ const ax = ra.left + ra.width / 2 - origin.left
67
+ const ay = ra.top + ra.height / 2 - origin.top
68
+ const bx = rb.left + rb.width / 2 - origin.left
69
+ const by = rb.top + rb.height / 2 - origin.top
70
+
71
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
72
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
73
+
74
+ next.push({
75
+ id: d.id,
76
+ x1: start.x,
77
+ y1: start.y,
78
+ x2: end.x,
79
+ y2: end.y,
80
+ done: board.getBlock(d.source)?.status === 'done',
81
+ })
82
+ }
83
+ segments.value = next
84
+ }
85
+
86
+ const { pause, resume } = useRafFn(recompute, { immediate: false })
87
+ onMounted(resume)
88
+ onBeforeUnmount(pause)
89
+ </script>
90
+
91
+ <template>
92
+ <svg ref="svg" class="pointer-events-none absolute inset-0 z-10 h-full w-full overflow-visible">
93
+ <defs>
94
+ <marker
95
+ id="task-arrow-pending"
96
+ viewBox="0 0 10 10"
97
+ refX="8"
98
+ refY="5"
99
+ markerWidth="6"
100
+ markerHeight="6"
101
+ orient="auto-start-reverse"
102
+ >
103
+ <path d="M0,0 L10,5 L0,10 z" fill="#f59e0b" />
104
+ </marker>
105
+ <marker
106
+ id="task-arrow-done"
107
+ viewBox="0 0 10 10"
108
+ refX="8"
109
+ refY="5"
110
+ markerWidth="6"
111
+ markerHeight="6"
112
+ orient="auto-start-reverse"
113
+ >
114
+ <path d="M0,0 L10,5 L0,10 z" fill="#64748b" />
115
+ </marker>
116
+ </defs>
117
+
118
+ <line
119
+ v-for="s in segments"
120
+ :key="s.id"
121
+ :x1="s.x1"
122
+ :y1="s.y1"
123
+ :x2="s.x2"
124
+ :y2="s.y2"
125
+ :stroke="s.done ? '#64748b' : '#f59e0b'"
126
+ :stroke-width="2"
127
+ :stroke-dasharray="s.done ? '0' : '5 4'"
128
+ :stroke-opacity="0.85"
129
+ :marker-end="s.done ? 'url(#task-arrow-done)' : 'url(#task-arrow-pending)'"
130
+ />
131
+ </svg>
132
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import type { AgentState, PipelineStep } from '~/types/domain'
3
+ import { AGENT_BY_KIND } from '~/utils/catalog'
4
+
5
+ const props = defineProps<{
6
+ step: PipelineStep
7
+ active?: boolean
8
+ size?: 'sm' | 'md'
9
+ }>()
10
+
11
+ const archetype = computed(() => AGENT_BY_KIND[props.step.agentKind])
12
+
13
+ const stateRing: Record<AgentState, string> = {
14
+ pending: 'ring-slate-600/60 opacity-60',
15
+ working: 'ring-indigo-400',
16
+ waiting_decision: 'ring-amber-400 board-pulse',
17
+ done: 'ring-emerald-400',
18
+ }
19
+
20
+ const stateIcon: Record<AgentState, string | null> = {
21
+ pending: null,
22
+ working: 'i-lucide-loader',
23
+ waiting_decision: 'i-lucide-circle-help',
24
+ done: 'i-lucide-check',
25
+ }
26
+
27
+ const dim = computed(() => (props.size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'))
28
+ </script>
29
+
30
+ <template>
31
+ <div class="flex flex-col items-center gap-1" :title="archetype.label">
32
+ <div
33
+ class="relative flex items-center justify-center rounded-full ring-2 transition"
34
+ :class="[dim, stateRing[step.state], active ? 'scale-110' : '']"
35
+ :style="{ backgroundColor: archetype.color + '22' }"
36
+ >
37
+ <UIcon :name="archetype.icon" class="text-base" :style="{ color: archetype.color }" />
38
+ <span
39
+ v-if="step.state === 'working'"
40
+ class="absolute -bottom-1 -right-1 rounded-full bg-slate-900 p-0.5"
41
+ >
42
+ <UIcon :name="stateIcon.working!" class="h-3 w-3 animate-spin text-indigo-300" />
43
+ </span>
44
+ <span
45
+ v-else-if="stateIcon[step.state]"
46
+ class="absolute -bottom-1 -right-1 rounded-full bg-slate-900 p-0.5"
47
+ >
48
+ <UIcon
49
+ :name="stateIcon[step.state]!"
50
+ class="h-3 w-3"
51
+ :class="step.state === 'done' ? 'text-emerald-300' : 'text-amber-300'"
52
+ />
53
+ </span>
54
+ </div>
55
+ <span v-if="size !== 'sm'" class="text-[10px] leading-none text-slate-300">
56
+ {{ archetype.label }}
57
+ </span>
58
+ </div>
59
+ </template>
@@ -0,0 +1,347 @@
1
+ <script setup lang="ts">
2
+ import type { Block, BlockStatus } from '~/types/domain'
3
+ import { BLOCK_TYPE_META, STATUS_META } from '~/utils/catalog'
4
+ import DecisionBadge from './DecisionBadge.vue'
5
+ import DraggableTask from './DraggableTask.vue'
6
+ import ModuleFrame from './ModuleFrame.vue'
7
+ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
8
+ import AgentStopButton from '~/components/board/AgentStopButton.vue'
9
+ import { useBlockDrag } from '~/composables/useBlockDrag'
10
+
11
+ // Vue Flow passes the node's `id` and `data` as props to custom node components.
12
+ // Only frames are rendered as board nodes; their tasks live inside the card.
13
+ const props = defineProps<{ id: string }>()
14
+
15
+ const board = useBoardStore()
16
+ const execution = useExecutionStore()
17
+ const ui = useUiStore()
18
+ const agentRuns = useAgentRunsStore()
19
+ const { lod } = useSemanticZoom()
20
+
21
+ const block = computed<Block | undefined>(() => board.getBlock(props.id))
22
+ const typeMeta = computed(() => (block.value ? BLOCK_TYPE_META[block.value.type] : null))
23
+
24
+ // ---- this service's children (tasks + modules) -----------------------------
25
+ const directTasks = computed(() => board.tasksOf(props.id))
26
+ const modules = computed(() => board.modulesOf(props.id))
27
+ const allTasks = computed(() => board.allTasksUnder(props.id))
28
+ const taskIds = computed(() => new Set(allTasks.value.map((t) => t.id)))
29
+ const taskCount = computed(() => allTasks.value.length)
30
+ const hasTasks = computed(() => taskCount.value > 0 || modules.value.length > 0)
31
+ const mergedTasks = computed(() => allTasks.value.filter((t) => t.status === 'done').length)
32
+ const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
33
+ const canvas = computed(() => board.containerSize(props.id))
34
+
35
+ // Frame status is derived from its tasks — services never reach "done".
36
+ const frameStatus = computed<BlockStatus>(() => board.frameStatus(props.id))
37
+ const statusMeta = computed(() => STATUS_META[frameStatus.value])
38
+ const accent = computed(() => statusMeta.value.color)
39
+ const FRAME_LABEL: Record<BlockStatus, string> = {
40
+ planned: 'No tasks',
41
+ ready: 'Live',
42
+ in_progress: 'Active',
43
+ blocked: 'Needs attention',
44
+ pr_ready: 'Active',
45
+ done: 'Live',
46
+ }
47
+ const statusLabel = computed(() => FRAME_LABEL[frameStatus.value])
48
+
49
+ const selected = computed(() => ui.selectedBlockId === props.id)
50
+ const expanded = computed(() => ui.isFrameExpanded(props.id))
51
+ // At far zoom we only ever show the chip; otherwise an expanded frame shows tasks.
52
+ const showExpanded = computed(() => expanded.value && lod.value !== 'far')
53
+
54
+ // Surface a pending decision from this frame OR any of its tasks.
55
+ const blockDecisions = computed(() =>
56
+ execution.openDecisions.filter((d) => d.blockId === props.id || taskIds.value.has(d.blockId)),
57
+ )
58
+
59
+ function openFirstDecision() {
60
+ const d = blockDecisions.value[0]
61
+ if (d) ui.openDecision(d.instanceId, d.decision.id)
62
+ }
63
+
64
+ function toggleExpand() {
65
+ ui.toggleFrame(props.id)
66
+ }
67
+
68
+ // Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
69
+ // so they're repositioned by grabbing the header handle instead. Frames live in
70
+ // free-floating flow space, hence `clamp: false`.
71
+ const { startDrag } = useBlockDrag()
72
+ function onFrameHandle(e: PointerEvent) {
73
+ if (block.value) startDrag(block.value, e, { clamp: false })
74
+ }
75
+
76
+ function addTask() {
77
+ board.addTask(props.id)
78
+ ui.expandFrame(props.id)
79
+ }
80
+
81
+ // A task needs merging → green pulse; a task needs a decision → amber pulse.
82
+ const pulseClass = computed(() => {
83
+ if (frameStatus.value === 'blocked') return 'board-pulse'
84
+ if (prTasks.value > 0) return 'board-pulse-green'
85
+ return ''
86
+ })
87
+
88
+ // ---- agent-run overlay ------------------------------------------------------
89
+ // When this service frame was materialised by a "bootstrap repo" run, surface its
90
+ // live status + subtask progress on the card (the user watches the container adapt
91
+ // + push the repo), and the shared failure banner + retry if it faulted. Derived
92
+ // from the unified agentRuns store, keyed by this frame's block id.
93
+ const run = computed(() => agentRuns.byBlock[props.id])
94
+ const bootstrapping = computed(
95
+ () => run.value?.kind === 'bootstrap' && run.value.status === 'running',
96
+ )
97
+ const runFailed = computed(() => run.value?.status === 'failed')
98
+ const bootstrapSubtasks = computed(() =>
99
+ bootstrapping.value ? (run.value?.subtasks ?? null) : null,
100
+ )
101
+ const bootstrapPct = computed(() => {
102
+ const s = bootstrapSubtasks.value
103
+ if (!s || s.total <= 0) return 0
104
+ return Math.min(100, Math.round((s.completed / s.total) * 100))
105
+ })
106
+ // The actual todo items the agent is working through, surfaced on the expanded
107
+ // card so a zoomed-in user sees the task list, not just the "N/M" count.
108
+ const bootstrapItems = computed(() => bootstrapSubtasks.value?.items ?? [])
109
+ const ITEM_ICON: Record<string, string> = {
110
+ completed: 'i-lucide-check-circle-2',
111
+ in_progress: 'i-lucide-loader-circle',
112
+ pending: 'i-lucide-circle',
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <div v-if="block" class="relative" :data-block-id="block.id">
118
+ <!-- decision indicator floats above the card at all zoom levels -->
119
+ <div v-if="blockDecisions.length" class="absolute -top-3 left-1/2 z-10 -translate-x-1/2">
120
+ <DecisionBadge
121
+ :count="blockDecisions.length"
122
+ :compact="lod === 'far'"
123
+ @open="openFirstDecision"
124
+ />
125
+ </div>
126
+
127
+ <!-- ===================== FAR: glanceable chip ===================== -->
128
+ <div
129
+ v-if="lod === 'far'"
130
+ class="flex w-44 items-center gap-2 rounded-xl border-2 px-3 py-3 shadow-lg backdrop-blur"
131
+ :class="[selected ? 'border-white' : '', pulseClass]"
132
+ :style="{ borderColor: accent, backgroundColor: accent + '26' }"
133
+ >
134
+ <span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: accent }" />
135
+ <span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
136
+ <UIcon
137
+ v-if="bootstrapping"
138
+ name="i-lucide-loader-circle"
139
+ class="ml-auto h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
140
+ title="Bootstrapping…"
141
+ />
142
+ <UIcon
143
+ v-else-if="runFailed"
144
+ name="i-lucide-alert-triangle"
145
+ class="ml-auto h-3.5 w-3.5 shrink-0 text-rose-400"
146
+ title="Run failed"
147
+ />
148
+ <span v-else-if="hasTasks" class="ml-auto shrink-0 text-[11px] text-slate-300">
149
+ {{ mergedTasks }}/{{ taskCount }}
150
+ </span>
151
+ </div>
152
+
153
+ <!-- ===================== COMPACT: summary (collapsed) ===================== -->
154
+ <div
155
+ v-else-if="!showExpanded"
156
+ class="w-56 overflow-hidden rounded-xl border bg-slate-900/90 shadow-xl backdrop-blur"
157
+ :class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
158
+ >
159
+ <div class="h-1.5 w-full" :style="{ backgroundColor: accent }" />
160
+ <!-- bootstrap-in-progress banner -->
161
+ <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2">
162
+ <div class="flex items-center gap-1.5 text-[11px]">
163
+ <UIcon
164
+ name="i-lucide-loader-circle"
165
+ class="h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
166
+ />
167
+ <span class="text-amber-300">Bootstrapping…</span>
168
+ <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
169
+ {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }}
170
+ </span>
171
+ </div>
172
+ <div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
173
+ <div
174
+ class="h-full rounded bg-amber-400 transition-all"
175
+ :style="{ width: bootstrapPct + '%' }"
176
+ />
177
+ </div>
178
+ <div v-if="run" class="mt-2 flex justify-end">
179
+ <AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
180
+ </div>
181
+ </div>
182
+ <!-- failed run: shared failure banner + retry -->
183
+ <div v-else-if="runFailed && run" class="p-2">
184
+ <AgentFailureCard :run="run" variant="compact" />
185
+ </div>
186
+ <div class="space-y-2 p-3">
187
+ <div class="flex items-center gap-2">
188
+ <UIcon
189
+ :name="typeMeta!.icon"
190
+ class="h-4 w-4 shrink-0"
191
+ :style="{ color: typeMeta!.accent }"
192
+ />
193
+ <span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
194
+ </div>
195
+ <div class="flex items-center justify-between">
196
+ <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
197
+ statusLabel
198
+ }}</UBadge>
199
+ <span class="text-[11px] text-slate-400"
200
+ >{{ taskCount }} task{{ taskCount === 1 ? '' : 's' }}</span
201
+ >
202
+ </div>
203
+ <button
204
+ type="button"
205
+ class="nodrag flex w-full items-center gap-1 rounded-md bg-slate-800/60 px-2 py-1 text-[10px] text-slate-300 hover:bg-slate-800"
206
+ @click.stop="toggleExpand"
207
+ >
208
+ <UIcon name="i-lucide-layers" class="h-3 w-3 text-slate-400" />
209
+ <span v-if="hasTasks">{{ mergedTasks }}/{{ taskCount }} merged</span>
210
+ <span v-else>No tasks yet</span>
211
+ <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR</span>
212
+ <UIcon name="i-lucide-chevron-down" class="ml-auto h-3 w-3" />
213
+ </button>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
218
+ <div
219
+ v-else
220
+ class="overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
221
+ :class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
222
+ >
223
+ <div class="h-1.5 w-full rounded-t-2xl" :style="{ backgroundColor: accent }" />
224
+ <!-- bootstrap-in-progress banner -->
225
+ <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-4 py-2">
226
+ <div class="flex items-center gap-1.5 text-xs">
227
+ <UIcon
228
+ name="i-lucide-loader-circle"
229
+ class="h-4 w-4 shrink-0 animate-spin text-amber-400"
230
+ />
231
+ <span class="text-amber-300">Bootstrapping repository…</span>
232
+ <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
233
+ {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }} steps
234
+ </span>
235
+ </div>
236
+ <div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
237
+ <div
238
+ class="h-full rounded bg-amber-400 transition-all"
239
+ :style="{ width: bootstrapPct + '%' }"
240
+ />
241
+ </div>
242
+ <!-- the actual todo list, once the agent has reported any items -->
243
+ <ul v-if="bootstrapItems.length" class="mt-2 space-y-1">
244
+ <li
245
+ v-for="(item, i) in bootstrapItems"
246
+ :key="i"
247
+ class="flex items-start gap-1.5 text-[11px]"
248
+ :class="
249
+ item.status === 'completed'
250
+ ? 'text-amber-200/60 line-through'
251
+ : item.status === 'in_progress'
252
+ ? 'text-amber-100'
253
+ : 'text-amber-200/80'
254
+ "
255
+ >
256
+ <UIcon
257
+ :name="ITEM_ICON[item.status]"
258
+ class="mt-px h-3 w-3 shrink-0"
259
+ :class="[
260
+ item.status === 'in_progress' ? 'animate-spin text-amber-400' : '',
261
+ item.status === 'completed' ? 'text-emerald-400' : 'text-amber-400/70',
262
+ ]"
263
+ />
264
+ <span>{{ item.label }}</span>
265
+ </li>
266
+ </ul>
267
+ <div v-if="run" class="mt-2 flex justify-end">
268
+ <AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
269
+ </div>
270
+ </div>
271
+ <!-- failed run: shared failure banner + retry -->
272
+ <div v-else-if="runFailed && run" class="p-3">
273
+ <AgentFailureCard :run="run" variant="expanded" />
274
+ </div>
275
+ <div class="space-y-3 p-4">
276
+ <!-- frame header (doubles as the drag handle for the expanded frame) -->
277
+ <div class="flex items-start justify-between gap-2">
278
+ <div
279
+ class="flex cursor-grab items-center gap-2 active:cursor-grabbing"
280
+ title="Drag service"
281
+ @pointerdown="onFrameHandle"
282
+ >
283
+ <div
284
+ class="flex h-8 w-8 items-center justify-center rounded-lg"
285
+ :style="{ backgroundColor: typeMeta!.accent + '22' }"
286
+ >
287
+ <UIcon :name="typeMeta!.icon" class="h-5 w-5" :style="{ color: typeMeta!.accent }" />
288
+ </div>
289
+ <div>
290
+ <div class="text-sm font-semibold text-white">{{ block.title }}</div>
291
+ <div class="text-[11px] text-slate-400">{{ typeMeta!.label }}</div>
292
+ </div>
293
+ </div>
294
+ <div class="flex items-center gap-1">
295
+ <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
296
+ statusLabel
297
+ }}</UBadge>
298
+ <UButton
299
+ class="nodrag"
300
+ size="xs"
301
+ variant="ghost"
302
+ color="neutral"
303
+ icon="i-lucide-plus"
304
+ title="Add task"
305
+ @click.stop="addTask"
306
+ />
307
+ <UButton
308
+ class="nodrag"
309
+ size="xs"
310
+ variant="ghost"
311
+ color="neutral"
312
+ icon="i-lucide-chevron-up"
313
+ title="Collapse"
314
+ @click.stop="toggleExpand"
315
+ />
316
+ </div>
317
+ </div>
318
+
319
+ <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
320
+ <span>{{ mergedTasks }}/{{ taskCount }} implemented</span>
321
+ <span v-if="modules.length"
322
+ >· {{ modules.length }} module{{ modules.length === 1 ? '' : 's' }}</span
323
+ >
324
+ <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR ready</span>
325
+ </div>
326
+
327
+ <!-- the 2D drop zone: modules and loose tasks live here, draggable -->
328
+ <div
329
+ :data-drop-zone="block.id"
330
+ class="nodrag relative rounded-xl bg-slate-950/40"
331
+ :style="{ width: canvas.w + 'px', height: canvas.h + 'px' }"
332
+ >
333
+ <ModuleFrame v-for="m in modules" :key="m.id" :module-id="m.id" />
334
+ <DraggableTask v-for="t in directTasks" :key="t.id" :task-id="t.id" />
335
+ <button
336
+ v-if="!hasTasks"
337
+ type="button"
338
+ class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
339
+ @click.stop="addTask"
340
+ >
341
+ <UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> Add the first task
342
+ </button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ count?: number
4
+ compact?: boolean
5
+ }>()
6
+ defineEmits<{ (e: 'open'): void }>()
7
+ </script>
8
+
9
+ <template>
10
+ <button
11
+ type="button"
12
+ class="board-pulse flex items-center gap-1 rounded-full bg-amber-500 px-2 py-0.5 text-xs font-semibold text-amber-950 shadow-lg transition hover:bg-amber-400"
13
+ @click.stop="$emit('open')"
14
+ >
15
+ <UIcon name="i-lucide-circle-help" class="h-3.5 w-3.5" />
16
+ <span v-if="!compact">Decision needed</span>
17
+ <span v-if="count && count > 1" class="rounded-full bg-amber-950/30 px-1">
18
+ {{ count }}
19
+ </span>
20
+ </button>
21
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import TaskCard from './TaskCard.vue'
3
+ import { useBlockDrag } from '~/composables/useBlockDrag'
4
+ import { FEATURE_META } from '~/utils/catalog'
5
+
6
+ const props = defineProps<{ taskId: string }>()
7
+ const board = useBoardStore()
8
+ const task = computed(() => board.getBlock(props.taskId))
9
+ const { draggingId, startDrag } = useBlockDrag()
10
+
11
+ // Once a task is merged it stops being a unit of work and becomes part of the
12
+ // architecture: we no longer show it as a "Done" card with a status, just the
13
+ // features it left behind (a merged task with no features simply disappears).
14
+ const merged = computed(() => task.value?.status === 'done')
15
+ const features = computed(() => task.value?.features ?? [])
16
+
17
+ function onHandle(e: PointerEvent) {
18
+ if (task.value) startDrag(task.value, e, { reparent: true })
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <template v-if="task">
24
+ <!-- merged → statusless features that "just exist" (no card, not draggable).
25
+ A merged task with no features renders nothing at all (arrows fall back
26
+ to its container), so we never leave a zero-size anchor behind. -->
27
+ <div
28
+ v-if="merged && features.length"
29
+ :data-block-id="task.id"
30
+ class="absolute flex w-[180px] flex-col gap-1"
31
+ :style="{ left: task.position.x + 'px', top: task.position.y + 'px', zIndex: 10 }"
32
+ >
33
+ <span
34
+ v-for="f in features"
35
+ :key="f"
36
+ class="inline-flex items-center gap-1 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-100"
37
+ :title="`Feature: ${f}`"
38
+ >
39
+ <UIcon
40
+ :name="FEATURE_META.icon"
41
+ class="h-3 w-3 shrink-0"
42
+ :style="{ color: FEATURE_META.color }"
43
+ />
44
+ <span class="truncate">{{ f }}</span>
45
+ </span>
46
+ </div>
47
+
48
+ <!-- in-flight task → draggable work card -->
49
+ <div
50
+ v-else-if="!merged"
51
+ class="absolute w-[180px]"
52
+ :style="{
53
+ left: task.position.x + 'px',
54
+ top: task.position.y + 'px',
55
+ zIndex: draggingId === taskId ? 60 : 10,
56
+ }"
57
+ >
58
+ <!-- drag handle -->
59
+ <div
60
+ class="nodrag flex cursor-grab items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing"
61
+ title="Drag task"
62
+ @pointerdown="onHandle"
63
+ >
64
+ <UIcon name="i-lucide-grip-horizontal" class="h-3 w-3 text-slate-500" />
65
+ </div>
66
+ <TaskCard :task-id="taskId" class="!rounded-t-none" />
67
+ </div>
68
+ </template>
69
+ </template>