@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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +18 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +146 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +347 -0
- package/app/components/board/nodes/DecisionBadge.vue +21 -0
- package/app/components/board/nodes/DraggableTask.vue +69 -0
- package/app/components/board/nodes/ModuleFrame.vue +70 -0
- package/app/components/board/nodes/TaskCard.vue +237 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +161 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/layout/BoardSwitcher.vue +202 -0
- package/app/components/layout/BoardToolbar.vue +109 -0
- package/app/components/layout/SideBar.vue +193 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/palettes/AgentPalette.vue +33 -0
- package/app/components/palettes/BlockPalette.vue +41 -0
- package/app/components/palettes/PipelinePalette.vue +74 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +296 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskExecution.vue +175 -0
- package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
- package/app/components/panels/inspector/TaskStructure.vue +139 -0
- package/app/components/pipeline/PipelineBuilder.vue +227 -0
- package/app/components/pipeline/PipelineProgress.vue +246 -0
- package/app/components/requirements/RequirementReviewModal.vue +328 -0
- package/app/components/scenarios/FeatureScenarios.vue +162 -0
- package/app/components/scenarios/ScenarioCard.vue +109 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +140 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
- package/app/composables/useApi.ts +535 -0
- package/app/composables/useBlockDrag.ts +75 -0
- package/app/composables/useBlockQueries.ts +136 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useSemanticZoom.ts +16 -0
- package/app/composables/useWorkspaceStream.ts +125 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +80 -0
- package/app/stores/accounts.ts +64 -0
- package/app/stores/agentRuns.ts +117 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/auth.ts +97 -0
- package/app/stores/board.spec.ts +197 -0
- package/app/stores/board.ts +147 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/documents.ts +165 -0
- package/app/stores/execution.ts +115 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +291 -0
- package/app/stores/models.ts +48 -0
- package/app/stores/pipelines.ts +77 -0
- package/app/stores/requirements.ts +133 -0
- package/app/stores/scenarios.spec.ts +82 -0
- package/app/stores/scenarios.ts +196 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +149 -0
- package/app/stores/ui.ts +204 -0
- package/app/stores/workspace.ts +201 -0
- package/app/types/accounts.ts +38 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/documents.ts +92 -0
- package/app/types/domain.ts +216 -0
- package/app/types/execution.ts +110 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +153 -0
- package/app/types/models.ts +48 -0
- package/app/types/requirements.ts +38 -0
- package/app/types/scenarios.ts +36 -0
- package/app/types/tasks.ts +67 -0
- package/app/utils/catalog.spec.ts +82 -0
- package/app/utils/catalog.ts +185 -0
- package/app/utils/dnd.ts +29 -0
- package/nuxt.config.ts +43 -0
- 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>
|