@cat-factory/app 0.6.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 +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -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 +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -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 +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- package/package.json +45 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { computed, type Ref } from 'vue'
|
|
2
|
+
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure, read-only queries over a board's blocks. Extracted from the board store
|
|
6
|
+
* so the (sizeable) derivation logic — hierarchy traversal, status/progress
|
|
7
|
+
* rollups and container sizing — lives in one focused, independently testable
|
|
8
|
+
* place. The store wires these against its reactive `blocks` cache and re-exposes
|
|
9
|
+
* them unchanged, so callers and tests are unaffected.
|
|
10
|
+
*/
|
|
11
|
+
export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
12
|
+
const byId = computed(() => {
|
|
13
|
+
const map = new Map<string, Block>()
|
|
14
|
+
for (const b of blocks.value) map.set(b.id, b)
|
|
15
|
+
return map
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function getBlock(id: string) {
|
|
19
|
+
return byId.value.get(id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
|
|
23
|
+
const frames = computed(() => blocks.value.filter((b) => (b.level ?? 'frame') === 'frame'))
|
|
24
|
+
|
|
25
|
+
/** Direct children of a block, in insertion order. */
|
|
26
|
+
function childrenOf(parentId: string) {
|
|
27
|
+
return blocks.value.filter((b) => b.parentId === parentId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Tasks directly inside a container (a service or a module). */
|
|
31
|
+
function tasksOf(containerId: string) {
|
|
32
|
+
return blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Modules (sub-frames) inside a service. */
|
|
36
|
+
function modulesOf(serviceId: string) {
|
|
37
|
+
return blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tasks anywhere under a container — directly, or nested inside its modules. */
|
|
41
|
+
function allTasksUnder(containerId: string): Block[] {
|
|
42
|
+
const direct = tasksOf(containerId)
|
|
43
|
+
const nested = modulesOf(containerId).flatMap((m) => tasksOf(m.id))
|
|
44
|
+
return [...direct, ...nested]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The top-level service a block ultimately belongs to. */
|
|
48
|
+
function serviceOf(block: Block): Block | undefined {
|
|
49
|
+
let cur: Block | undefined = block
|
|
50
|
+
while (cur && cur.level !== 'frame') {
|
|
51
|
+
cur = cur.parentId ? getBlock(cur.parentId) : undefined
|
|
52
|
+
}
|
|
53
|
+
return cur
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** All tasks across every service (used for the dependency picker). */
|
|
57
|
+
const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
|
|
58
|
+
|
|
59
|
+
/** A task's dependencies that are not yet merged (i.e. block it from running). */
|
|
60
|
+
function unmetDeps(taskId: string) {
|
|
61
|
+
const t = getBlock(taskId)
|
|
62
|
+
if (!t) return [] as Block[]
|
|
63
|
+
return t.dependsOn
|
|
64
|
+
.map((id) => getBlock(id))
|
|
65
|
+
.filter((b): b is Block => !!b && b.status !== 'done')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A task may run only once all of its dependencies have merged. */
|
|
69
|
+
function isRunnable(taskId: string) {
|
|
70
|
+
return unmetDeps(taskId).length === 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Container status/progress are derived from the tasks under it (containers have no PR). */
|
|
74
|
+
function frameProgress(frameId: string) {
|
|
75
|
+
const tasks = allTasksUnder(frameId)
|
|
76
|
+
if (tasks.length === 0) return getBlock(frameId)?.progress ?? 0
|
|
77
|
+
const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 1 : t.progress), 0)
|
|
78
|
+
return sum / tasks.length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A frame is a long-lived service: it never reaches a terminal "done" —
|
|
83
|
+
* tasks keep appearing. So its status reflects current *activity*, mapped
|
|
84
|
+
* onto the shared status palette but capped below `done`:
|
|
85
|
+
* planned → no tasks yet (empty)
|
|
86
|
+
* ready → has tasks but nothing active (idle / caught up / "live")
|
|
87
|
+
* in_progress → at least one task running or with an open PR
|
|
88
|
+
* blocked → at least one task needs a decision
|
|
89
|
+
*/
|
|
90
|
+
function frameStatus(frameId: string): BlockStatus {
|
|
91
|
+
const tasks = allTasksUnder(frameId)
|
|
92
|
+
if (tasks.length === 0) return 'planned'
|
|
93
|
+
if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
|
|
94
|
+
if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
|
|
95
|
+
return 'in_progress'
|
|
96
|
+
return 'ready'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The natural extent of a container's inner 2D canvas — the smallest size that
|
|
101
|
+
* still fits all its children. This is the floor a resizable frame can never be
|
|
102
|
+
* dragged below (so tasks/modules are never clipped).
|
|
103
|
+
*/
|
|
104
|
+
function contentSize(id: string): { w: number; h: number } {
|
|
105
|
+
const b = getBlock(id)
|
|
106
|
+
const isModule = b?.level === 'module'
|
|
107
|
+
const TASK_W = 180
|
|
108
|
+
const TASK_H = 160
|
|
109
|
+
const headerH = isModule ? 30 : 0
|
|
110
|
+
let w = isModule ? 200 : 360
|
|
111
|
+
let inner = isModule ? 60 : 220
|
|
112
|
+
for (const t of tasksOf(id)) {
|
|
113
|
+
w = Math.max(w, t.position.x + TASK_W + 12)
|
|
114
|
+
inner = Math.max(inner, t.position.y + TASK_H + 12)
|
|
115
|
+
}
|
|
116
|
+
for (const m of modulesOf(id)) {
|
|
117
|
+
const s = containerSize(m.id)
|
|
118
|
+
w = Math.max(w, m.position.x + s.w + 12)
|
|
119
|
+
inner = Math.max(inner, m.position.y + s.h + 12)
|
|
120
|
+
}
|
|
121
|
+
return { w, h: inner + headerH }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pixel size of a container's inner 2D canvas. The content extent is the floor;
|
|
126
|
+
* a frame the user has resized (dragging its borders) uses its stored `size`
|
|
127
|
+
* when that is larger, so an explicit size grows the frame but never shrinks it
|
|
128
|
+
* below its contents.
|
|
129
|
+
*/
|
|
130
|
+
function containerSize(id: string): { w: number; h: number } {
|
|
131
|
+
const content = contentSize(id)
|
|
132
|
+
const stored = getBlock(id)?.size
|
|
133
|
+
if (!stored) return content
|
|
134
|
+
return { w: Math.max(content.w, stored.w), h: Math.max(content.h, stored.h) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
byId,
|
|
139
|
+
getBlock,
|
|
140
|
+
frames,
|
|
141
|
+
allTasks,
|
|
142
|
+
childrenOf,
|
|
143
|
+
tasksOf,
|
|
144
|
+
modulesOf,
|
|
145
|
+
allTasksUnder,
|
|
146
|
+
serviceOf,
|
|
147
|
+
unmetDeps,
|
|
148
|
+
isRunnable,
|
|
149
|
+
frameProgress,
|
|
150
|
+
frameStatus,
|
|
151
|
+
contentSize,
|
|
152
|
+
containerSize,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useVueFlow } from '@vue-flow/core'
|
|
2
|
+
|
|
3
|
+
/** Stable id for the main board's Vue Flow instance. Passing this id to
|
|
4
|
+
* useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
|
|
5
|
+
export const BOARD_FLOW_ID = 'board'
|
|
6
|
+
|
|
7
|
+
/** Camera controls for the main board, usable outside the canvas component. */
|
|
8
|
+
export function useBoardFlow() {
|
|
9
|
+
const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
|
|
10
|
+
return { fitView, zoomIn, zoomOut, viewport }
|
|
11
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
// Shared model + orchestration for attaching external context (imported docs and
|
|
4
|
+
// tracker issues) to a board block. A "pending" item is something the user has
|
|
5
|
+
// chosen to attach but which is only linked once the block exists — so the task
|
|
6
|
+
// creation popup can collect selections up front and commit them after the task
|
|
7
|
+
// is created. Search hits and pasted URLs carry `needsImport: true`: they are not
|
|
8
|
+
// yet projected locally, so they are imported (fetched + persisted) before being
|
|
9
|
+
// linked; already-imported items are linked directly. Reused wherever context is
|
|
10
|
+
// attached (the add-task popup today; the inspector can adopt it later).
|
|
11
|
+
|
|
12
|
+
export interface PendingContext {
|
|
13
|
+
kind: 'document' | 'task'
|
|
14
|
+
source: DocumentSourceKind | TaskSourceKind
|
|
15
|
+
/** A canonical external id (already-imported / search hit) or a pasted URL/ref. */
|
|
16
|
+
externalId: string
|
|
17
|
+
title: string
|
|
18
|
+
/** Secondary line: an issue status, a source label, the raw URL, … */
|
|
19
|
+
subtitle?: string
|
|
20
|
+
/** Lucide icon for the row. */
|
|
21
|
+
icon?: string
|
|
22
|
+
/** True when the item must be imported before it can be linked. */
|
|
23
|
+
needsImport: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Stable key for a pending item, used for dedupe + selection toggles. */
|
|
27
|
+
export function contextKey(c: Pick<PendingContext, 'kind' | 'source' | 'externalId'>): string {
|
|
28
|
+
return `${c.kind}:${c.source}:${c.externalId}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useContextLinking() {
|
|
32
|
+
const documents = useDocumentsStore()
|
|
33
|
+
const tasks = useTasksStore()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Import (when needed) then link every pending item to `blockId`. Each failure
|
|
37
|
+
* is counted rather than aborting the batch, so one bad attachment doesn't sink
|
|
38
|
+
* the rest; returns how many failed.
|
|
39
|
+
*/
|
|
40
|
+
async function linkPending(blockId: string, items: PendingContext[]): Promise<number> {
|
|
41
|
+
let failed = 0
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
try {
|
|
44
|
+
if (item.kind === 'document') {
|
|
45
|
+
const source = item.source as DocumentSourceKind
|
|
46
|
+
const externalId = item.needsImport
|
|
47
|
+
? (await documents.importDocument(source, item.externalId)).externalId
|
|
48
|
+
: item.externalId
|
|
49
|
+
await documents.linkToBlock(blockId, source, externalId)
|
|
50
|
+
} else {
|
|
51
|
+
const source = item.source as TaskSourceKind
|
|
52
|
+
const externalId = item.needsImport
|
|
53
|
+
? (await tasks.importTask(source, item.externalId)).externalId
|
|
54
|
+
: item.externalId
|
|
55
|
+
await tasks.linkToBlock(blockId, source, externalId)
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
failed++
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failed
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { linkPending }
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Block } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Labels a dependency block, qualifying it with its owning frame when it lives
|
|
5
|
+
* in a different container than the task depending on it (a "cross-frame" edge).
|
|
6
|
+
* Shared by the inspector and the task card so the two read identically.
|
|
7
|
+
*/
|
|
8
|
+
export function useDepLabels() {
|
|
9
|
+
const board = useBoardStore()
|
|
10
|
+
|
|
11
|
+
/** Title of a block's parent container, if any. */
|
|
12
|
+
function frameTitle(b: Block): string | undefined {
|
|
13
|
+
return b.parentId ? board.getBlock(b.parentId)?.title : undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `Parent / Title` when `dep` lives in a different container than
|
|
18
|
+
* `contextParentId`, otherwise just the dependency's own title.
|
|
19
|
+
*/
|
|
20
|
+
function depLabel(dep: Block, contextParentId?: string | null): string {
|
|
21
|
+
const f = frameTitle(dep)
|
|
22
|
+
return f && dep.parentId !== contextParentId ? `${f} / ${dep.title}` : dep.title
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { frameTitle, depLabel }
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { Block } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pointer-driven resizing for service frames (Miro-style border drag). The drag
|
|
6
|
+
* delta is divided by the board zoom so the edge tracks the cursor, and the new
|
|
7
|
+
* size is clamped to the frame's content extent so dragging in never clips the
|
|
8
|
+
* tasks/modules inside. The frame grows live (the store block is mutated in place,
|
|
9
|
+
* which `containerSize` reads back), and the final size is persisted once on
|
|
10
|
+
* release rather than on every move.
|
|
11
|
+
*/
|
|
12
|
+
export function useFrameResize() {
|
|
13
|
+
const board = useBoardStore()
|
|
14
|
+
const ui = useUiStore()
|
|
15
|
+
/** Id of the frame currently being resized, for cursor/handle styling. */
|
|
16
|
+
const resizingId = ref<string | null>(null)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Begin a resize from one of the frame's edges/corner. `edge` selects which
|
|
20
|
+
* dimensions move: `'e'` width only, `'s'` height only, `'se'` both.
|
|
21
|
+
*/
|
|
22
|
+
function startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
23
|
+
if (e.button !== 0) return
|
|
24
|
+
e.preventDefault()
|
|
25
|
+
e.stopPropagation()
|
|
26
|
+
const startX = e.clientX
|
|
27
|
+
const startY = e.clientY
|
|
28
|
+
// The content extent is the floor — never shrink a frame below its tasks.
|
|
29
|
+
const min = board.contentSize(block.id)
|
|
30
|
+
// Seed from the current rendered size so the first move doesn't jump.
|
|
31
|
+
const start = board.containerSize(block.id)
|
|
32
|
+
resizingId.value = block.id
|
|
33
|
+
|
|
34
|
+
const onMove = (ev: PointerEvent) => {
|
|
35
|
+
const z = ui.zoom || 1
|
|
36
|
+
const w = edge === 's' ? start.w : Math.max(min.w, start.w + (ev.clientX - startX) / z)
|
|
37
|
+
const h = edge === 'e' ? start.h : Math.max(min.h, start.h + (ev.clientY - startY) / z)
|
|
38
|
+
// Optimistic, local-only: mutate the cached block so the frame grows live
|
|
39
|
+
// without a round-trip on every pointer move.
|
|
40
|
+
block.size = { w: Math.round(w), h: Math.round(h) }
|
|
41
|
+
}
|
|
42
|
+
const onUp = () => {
|
|
43
|
+
window.removeEventListener('pointermove', onMove)
|
|
44
|
+
window.removeEventListener('pointerup', onUp)
|
|
45
|
+
resizingId.value = null
|
|
46
|
+
// Persist the final size once (also re-applies it as the authoritative block).
|
|
47
|
+
if (block.size) void board.updateBlock(block.id, { size: block.size })
|
|
48
|
+
}
|
|
49
|
+
window.addEventListener('pointermove', onMove)
|
|
50
|
+
window.addEventListener('pointerup', onUp)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { resizingId, startResize }
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract for a dedicated result-view window (the `resultView` seam — see
|
|
3
|
+
* `~/utils/catalog`, `StepResultViewHost.vue`). Every such window resolves the same state
|
|
4
|
+
* from `ui.resultView`, closes the same way, and (when it loads data) must fetch on open.
|
|
5
|
+
*
|
|
6
|
+
* `StepResultViewHost` mounts each window FRESH every time it opens, so a per-window
|
|
7
|
+
* `watch` would have to be `immediate` to fetch on the initial mount — easy to forget, and
|
|
8
|
+
* forgetting it makes the window show an empty state for whichever navigation route didn't
|
|
9
|
+
* happen to warm a cache first. Centralising the contract here means a window can't drift:
|
|
10
|
+
* declare an `onOpen` loader and it fires immediately on mount AND on any later block switch,
|
|
11
|
+
* regardless of how the window was opened.
|
|
12
|
+
*
|
|
13
|
+
* A synchronous window (one that reads its data straight off the execution step, like the
|
|
14
|
+
* test report) simply omits `onOpen`.
|
|
15
|
+
*/
|
|
16
|
+
export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string) => void }) {
|
|
17
|
+
const ui = useUiStore()
|
|
18
|
+
|
|
19
|
+
const open = computed(() => ui.resultView?.view === viewId)
|
|
20
|
+
// Null whenever this window isn't the active view, so a stale id from another window's
|
|
21
|
+
// open can never leak into this one.
|
|
22
|
+
const blockId = computed(() => (open.value ? ui.resultView!.blockId : null))
|
|
23
|
+
const instanceId = computed(() => (open.value ? ui.resultView!.instanceId : null))
|
|
24
|
+
const stepIndex = computed(() => (open.value ? ui.resultView!.stepIndex : null))
|
|
25
|
+
|
|
26
|
+
function close() {
|
|
27
|
+
ui.closeResultView()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function onKey(e: KeyboardEvent) {
|
|
31
|
+
if (e.key === 'Escape' && open.value) close()
|
|
32
|
+
}
|
|
33
|
+
onMounted(() => window.addEventListener('keydown', onKey))
|
|
34
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
35
|
+
|
|
36
|
+
// The load-on-open contract: fire immediately on mount and on any later block switch.
|
|
37
|
+
if (opts?.onOpen) {
|
|
38
|
+
watch(
|
|
39
|
+
blockId,
|
|
40
|
+
(id) => {
|
|
41
|
+
if (id) opts.onOpen!(id)
|
|
42
|
+
},
|
|
43
|
+
{ immediate: true },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { open, blockId, instanceId, stepIndex, close }
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useRequirementsStore } from '~/stores/requirements'
|
|
2
|
+
import { useClarityStore } from '~/stores/clarity'
|
|
3
|
+
|
|
4
|
+
/** The async stage an iterative reviewer gate is mid-cycle in, or null. */
|
|
5
|
+
export type ReviewStage = 'incorporating' | 'reviewing' | null
|
|
6
|
+
|
|
7
|
+
// Both iterative reviewer gates (`requirements-review` over a feature brief and
|
|
8
|
+
// `clarity-review` over a bug report) drive the same answer → incorporate → re-review
|
|
9
|
+
// loop, and while a review is folding answers / re-reviewing in the durable driver it
|
|
10
|
+
// needs NO human — so its parked approval must be SUPPRESSED on the board/inspector and a
|
|
11
|
+
// working indicator shown instead. This composable maps a review gate to the store that
|
|
12
|
+
// tracks its loop, so the board surfaces (BlockNode / TaskCard / TaskExecution) handle
|
|
13
|
+
// both review kinds through one helper rather than special-casing each kind separately.
|
|
14
|
+
|
|
15
|
+
export function useReviewStage() {
|
|
16
|
+
const requirements = useRequirementsStore()
|
|
17
|
+
const clarity = useClarityStore()
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The background stage for a block regardless of which review kind drives it. A task is
|
|
21
|
+
* either a feature task (requirements review) or a bug task (clarity review), so at most
|
|
22
|
+
* one store has a live stage for it.
|
|
23
|
+
*/
|
|
24
|
+
function stageForBlock(blockId: string): ReviewStage {
|
|
25
|
+
return requirements.backgroundStage(blockId) ?? clarity.backgroundStage(blockId)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a specific review-gate approval is mid-cycle background work — keyed off the
|
|
30
|
+
* approval's own `agentKind` so a non-review approval on the same block is never
|
|
31
|
+
* suppressed by a coincidental review stage.
|
|
32
|
+
*/
|
|
33
|
+
function isBackground(agentKind: string | undefined, blockId: string): boolean {
|
|
34
|
+
if (agentKind === 'requirements-review') return requirements.backgroundStage(blockId) != null
|
|
35
|
+
if (agentKind === 'clarity-review') return clarity.backgroundStage(blockId) != null
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { stageForBlock, isBackground }
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LodLevel } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/** The LOD scale, shallow → deep. Index order lets callers ask "is at least". */
|
|
4
|
+
export const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
|
|
5
|
+
|
|
6
|
+
/** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
|
|
7
|
+
* and the drill-down focus view so both honour the same thresholds.
|
|
8
|
+
*
|
|
9
|
+
* Past `close` (where a frame opens to show its tasks) two deeper bands drill
|
|
10
|
+
* into an individual task: `steps` reveals its build-pipeline steps, `subtasks`
|
|
11
|
+
* expands each step's live todo breakdown — the spatial analogue of opening a
|
|
12
|
+
* task in the inspector. */
|
|
13
|
+
export function zoomToLod(zoom: number): LodLevel {
|
|
14
|
+
if (zoom < 0.6) return 'far'
|
|
15
|
+
if (zoom < 1.2) return 'mid'
|
|
16
|
+
if (zoom < 1.8) return 'close'
|
|
17
|
+
if (zoom < 2.4) return 'steps'
|
|
18
|
+
return 'subtasks'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when `lod` is at least as deep as `min` on the LOD scale. */
|
|
22
|
+
export function lodAtLeast(lod: LodLevel, min: LodLevel): boolean {
|
|
23
|
+
return LOD_ORDER.indexOf(lod) >= LOD_ORDER.indexOf(min)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reactive LOD bound to the global UI zoom (set by the board canvas). */
|
|
27
|
+
export function useSemanticZoom() {
|
|
28
|
+
const ui = useUiStore()
|
|
29
|
+
const lod = computed<LodLevel>(() => zoomToLod(ui.zoom))
|
|
30
|
+
return { lod, zoom: computed(() => ui.zoom) }
|
|
31
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { ref, computed, nextTick, watch } from 'vue'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { sliceSource } from '~/utils/agentOutput'
|
|
4
|
+
|
|
5
|
+
/** A draft per-block review comment, anchored to a source range of the output. */
|
|
6
|
+
interface DraftComment {
|
|
7
|
+
srcStart: number
|
|
8
|
+
srcEnd: number
|
|
9
|
+
quotedSource: string
|
|
10
|
+
body: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The GitHub-style approval/review state machine for a pending gate step. When the
|
|
15
|
+
* step's gate is pending the prose reader doubles as a review surface: the human can
|
|
16
|
+
* comment on individual source-mapped blocks, leave overall feedback, edit the
|
|
17
|
+
* conclusions in place, then Approve / Request changes / Reject. This composable owns
|
|
18
|
+
* all of that draft state + the in-document highlight syncing; the parent supplies the
|
|
19
|
+
* live step, the scroll container (for highlight lookups), the run/approval ids, and a
|
|
20
|
+
* `close` callback the actions invoke once they resolve.
|
|
21
|
+
*/
|
|
22
|
+
export function useStepApproval(opts: {
|
|
23
|
+
step: () => PipelineStep | null
|
|
24
|
+
scrollEl: () => HTMLElement | null
|
|
25
|
+
instanceId: () => string | undefined
|
|
26
|
+
approvalId: () => string | null
|
|
27
|
+
approvalPending: () => boolean
|
|
28
|
+
companionExceeded: () => boolean
|
|
29
|
+
close: () => void
|
|
30
|
+
}) {
|
|
31
|
+
const execution = useExecutionStore()
|
|
32
|
+
|
|
33
|
+
const reviewComments = ref<DraftComment[]>([])
|
|
34
|
+
const feedback = ref('')
|
|
35
|
+
const submitting = ref(false)
|
|
36
|
+
const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
|
|
37
|
+
const draftBody = ref('')
|
|
38
|
+
|
|
39
|
+
// "Approve with corrections" mode: a deliberate state distinct from the read-only
|
|
40
|
+
// review — the human edits the conclusions directly and those edits flow forward as
|
|
41
|
+
// the approved proposal. It CANNOT be mixed with the request-changes/comments path.
|
|
42
|
+
const editing = ref(false)
|
|
43
|
+
const draftProposal = ref('')
|
|
44
|
+
|
|
45
|
+
// Reject stops the whole run, so it's a two-step inline confirm (no native dialog).
|
|
46
|
+
const rejectArmed = ref(false)
|
|
47
|
+
|
|
48
|
+
const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
|
|
49
|
+
|
|
50
|
+
/** Toggle the highlight classes on commented / selected blocks within the reader. */
|
|
51
|
+
function syncHighlights() {
|
|
52
|
+
const root = opts.scrollEl()
|
|
53
|
+
if (!root) return
|
|
54
|
+
const commented = new Set(reviewComments.value.map(blockKey))
|
|
55
|
+
const selected = draftTarget.value ? blockKey(draftTarget.value) : null
|
|
56
|
+
for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
|
|
57
|
+
const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
|
|
58
|
+
el.classList.toggle('cf-commented', commented.has(key))
|
|
59
|
+
el.classList.toggle('cf-selected', key === selected)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Click a rendered block to start commenting on it (links keep working). */
|
|
64
|
+
function onProseClick(e: MouseEvent) {
|
|
65
|
+
if (!opts.approvalPending() || opts.companionExceeded() || editing.value) return
|
|
66
|
+
const target = e.target as HTMLElement
|
|
67
|
+
if (target.closest('a')) return
|
|
68
|
+
const blockEl = target.closest('[data-src-start]') as HTMLElement | null
|
|
69
|
+
if (!blockEl) return
|
|
70
|
+
const srcStart = Number(blockEl.getAttribute('data-src-start'))
|
|
71
|
+
const srcEnd = Number(blockEl.getAttribute('data-src-end'))
|
|
72
|
+
if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
|
|
73
|
+
draftTarget.value = {
|
|
74
|
+
srcStart,
|
|
75
|
+
srcEnd,
|
|
76
|
+
quotedSource: sliceSource(opts.step()?.output ?? '', srcStart, srcEnd),
|
|
77
|
+
}
|
|
78
|
+
draftBody.value = ''
|
|
79
|
+
void nextTick(syncHighlights)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addDraftComment() {
|
|
83
|
+
if (!draftTarget.value || !draftBody.value.trim()) return
|
|
84
|
+
reviewComments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
|
|
85
|
+
draftTarget.value = null
|
|
86
|
+
draftBody.value = ''
|
|
87
|
+
void nextTick(syncHighlights)
|
|
88
|
+
}
|
|
89
|
+
function cancelDraft() {
|
|
90
|
+
draftTarget.value = null
|
|
91
|
+
draftBody.value = ''
|
|
92
|
+
void nextTick(syncHighlights)
|
|
93
|
+
}
|
|
94
|
+
function removeComment(idx: number) {
|
|
95
|
+
reviewComments.value.splice(idx, 1)
|
|
96
|
+
void nextTick(syncHighlights)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const canRequestChanges = computed(
|
|
100
|
+
() => !!feedback.value.trim() || reviewComments.value.length > 0,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Plain approve: accept the agent's proposal verbatim and advance.
|
|
104
|
+
async function approve() {
|
|
105
|
+
const id = opts.approvalId()
|
|
106
|
+
if (!opts.instanceId() || !id || submitting.value) return
|
|
107
|
+
submitting.value = true
|
|
108
|
+
try {
|
|
109
|
+
await execution.approveStep(opts.instanceId()!, id)
|
|
110
|
+
opts.close()
|
|
111
|
+
} finally {
|
|
112
|
+
submitting.value = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function startEditing() {
|
|
117
|
+
draftProposal.value = opts.step()?.output ?? ''
|
|
118
|
+
editing.value = true
|
|
119
|
+
// Editing and the review/reject path are mutually exclusive — clear the other.
|
|
120
|
+
rejectArmed.value = false
|
|
121
|
+
draftTarget.value = null
|
|
122
|
+
void nextTick(syncHighlights)
|
|
123
|
+
}
|
|
124
|
+
function cancelEditing() {
|
|
125
|
+
editing.value = false
|
|
126
|
+
draftProposal.value = ''
|
|
127
|
+
}
|
|
128
|
+
async function approveWithEdits() {
|
|
129
|
+
const id = opts.approvalId()
|
|
130
|
+
if (!opts.instanceId() || !id || submitting.value) return
|
|
131
|
+
submitting.value = true
|
|
132
|
+
try {
|
|
133
|
+
await execution.approveStep(opts.instanceId()!, id, draftProposal.value)
|
|
134
|
+
opts.close()
|
|
135
|
+
} finally {
|
|
136
|
+
submitting.value = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function requestChanges() {
|
|
140
|
+
const id = opts.approvalId()
|
|
141
|
+
if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
|
|
142
|
+
submitting.value = true
|
|
143
|
+
try {
|
|
144
|
+
await execution.requestStepChanges(opts.instanceId()!, id, {
|
|
145
|
+
feedback: feedback.value.trim() || undefined,
|
|
146
|
+
comments: reviewComments.value.length
|
|
147
|
+
? reviewComments.value.map((c) => ({
|
|
148
|
+
quotedSource: c.quotedSource,
|
|
149
|
+
srcStart: c.srcStart,
|
|
150
|
+
srcEnd: c.srcEnd,
|
|
151
|
+
body: c.body,
|
|
152
|
+
}))
|
|
153
|
+
: undefined,
|
|
154
|
+
})
|
|
155
|
+
opts.close()
|
|
156
|
+
} finally {
|
|
157
|
+
submitting.value = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function armReject() {
|
|
161
|
+
rejectArmed.value = true
|
|
162
|
+
}
|
|
163
|
+
function disarmReject() {
|
|
164
|
+
rejectArmed.value = false
|
|
165
|
+
}
|
|
166
|
+
async function reject() {
|
|
167
|
+
const id = opts.approvalId()
|
|
168
|
+
if (!opts.instanceId() || !id || submitting.value) return
|
|
169
|
+
submitting.value = true
|
|
170
|
+
try {
|
|
171
|
+
await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)
|
|
172
|
+
opts.close()
|
|
173
|
+
} finally {
|
|
174
|
+
submitting.value = false
|
|
175
|
+
rejectArmed.value = false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reset the approve-with-edits / reject sub-states so reopening the same step is
|
|
181
|
+
* clean (the step-change watch only fires when the step key actually changes).
|
|
182
|
+
*/
|
|
183
|
+
function resetForClose() {
|
|
184
|
+
editing.value = false
|
|
185
|
+
draftProposal.value = ''
|
|
186
|
+
rejectArmed.value = false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Full reset of every draft when a different gate/step opens. */
|
|
190
|
+
function resetForStep() {
|
|
191
|
+
reviewComments.value = []
|
|
192
|
+
feedback.value = ''
|
|
193
|
+
draftTarget.value = null
|
|
194
|
+
draftBody.value = ''
|
|
195
|
+
rejectArmed.value = false
|
|
196
|
+
editing.value = false
|
|
197
|
+
draftProposal.value = ''
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Keep the in-document highlights in sync as the output renders or comments change.
|
|
201
|
+
watch(
|
|
202
|
+
[opts.approvalPending, () => opts.step()?.output, reviewComments, draftTarget],
|
|
203
|
+
() => void nextTick(syncHighlights),
|
|
204
|
+
{ deep: true },
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
reviewComments,
|
|
209
|
+
feedback,
|
|
210
|
+
submitting,
|
|
211
|
+
draftTarget,
|
|
212
|
+
draftBody,
|
|
213
|
+
editing,
|
|
214
|
+
draftProposal,
|
|
215
|
+
rejectArmed,
|
|
216
|
+
canRequestChanges,
|
|
217
|
+
syncHighlights,
|
|
218
|
+
onProseClick,
|
|
219
|
+
addDraftComment,
|
|
220
|
+
cancelDraft,
|
|
221
|
+
removeComment,
|
|
222
|
+
approve,
|
|
223
|
+
startEditing,
|
|
224
|
+
cancelEditing,
|
|
225
|
+
approveWithEdits,
|
|
226
|
+
requestChanges,
|
|
227
|
+
armReject,
|
|
228
|
+
disarmReject,
|
|
229
|
+
reject,
|
|
230
|
+
resetForClose,
|
|
231
|
+
resetForStep,
|
|
232
|
+
}
|
|
233
|
+
}
|