@cat-factory/app 0.6.0 → 0.7.3
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 -21
- package/app/components/board/ContextPicker.vue +367 -367
- package/app/components/gates/GateResultView.vue +90 -12
- package/app/components/layout/SideBar.vue +11 -0
- package/app/components/observability/StepMetricsBar.vue +102 -102
- package/app/components/observability/StepModelActivity.vue +49 -0
- package/app/components/panels/ObservabilityPanel.vue +1 -1
- package/app/components/panels/StepMetadataCard.vue +4 -16
- package/app/components/panels/StepRunMeta.vue +105 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
- package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -124
- package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
- package/app/components/testing/TestReportWindow.vue +17 -8
- package/app/composables/useBlockQueries.ts +154 -154
- package/app/composables/useContextLinking.ts +65 -65
- package/app/composables/useFrameResize.ts +54 -54
- package/app/pages/index.vue +2 -0
- package/app/stores/documents.ts +176 -176
- package/app/stores/services.ts +87 -87
- package/app/stores/tracker.ts +39 -27
- package/app/stores/ui.ts +12 -0
- package/app/types/documents.ts +104 -104
- package/app/types/domain.ts +5 -1
- package/app/types/execution.ts +18 -0
- package/app/types/github.ts +173 -173
- package/app/types/services.ts +27 -27
- package/app/types/tasks.ts +82 -82
- package/app/types/tracker.ts +27 -18
- package/app/utils/agentOutput.spec.ts +128 -128
- package/app/utils/agentOutput.ts +173 -173
- package/app/utils/observability.ts +52 -52
- package/package.json +6 -1
|
@@ -1,154 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,65 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,54 +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
|
-
}
|
|
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
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -26,6 +26,7 @@ import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
|
26
26
|
import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
|
|
27
27
|
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
28
28
|
import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
|
|
29
|
+
import IssueTrackerWritebackPanel from '~/components/settings/IssueTrackerWritebackPanel.vue'
|
|
29
30
|
import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
|
|
30
31
|
import DatadogPanel from '~/components/settings/DatadogPanel.vue'
|
|
31
32
|
import ModelDefaultsPanel from '~/components/settings/ModelDefaultsPanel.vue'
|
|
@@ -115,6 +116,7 @@ watch(
|
|
|
115
116
|
<FragmentLibraryPanel />
|
|
116
117
|
<CommandBar />
|
|
117
118
|
<MergeThresholdsPanel />
|
|
119
|
+
<IssueTrackerWritebackPanel />
|
|
118
120
|
<WorkspaceSettingsPanel />
|
|
119
121
|
<DatadogPanel />
|
|
120
122
|
<ModelDefaultsPanel />
|