@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.
Files changed (33) hide show
  1. package/LICENSE +21 -21
  2. package/app/components/board/ContextPicker.vue +367 -367
  3. package/app/components/gates/GateResultView.vue +90 -12
  4. package/app/components/layout/SideBar.vue +11 -0
  5. package/app/components/observability/StepMetricsBar.vue +102 -102
  6. package/app/components/observability/StepModelActivity.vue +49 -0
  7. package/app/components/panels/ObservabilityPanel.vue +1 -1
  8. package/app/components/panels/StepMetadataCard.vue +4 -16
  9. package/app/components/panels/StepRunMeta.vue +105 -0
  10. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
  11. package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
  12. package/app/components/recurring/RecurrenceEditor.vue +124 -124
  13. package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
  14. package/app/components/testing/TestReportWindow.vue +17 -8
  15. package/app/composables/useBlockQueries.ts +154 -154
  16. package/app/composables/useContextLinking.ts +65 -65
  17. package/app/composables/useFrameResize.ts +54 -54
  18. package/app/pages/index.vue +2 -0
  19. package/app/stores/documents.ts +176 -176
  20. package/app/stores/services.ts +87 -87
  21. package/app/stores/tracker.ts +39 -27
  22. package/app/stores/ui.ts +12 -0
  23. package/app/types/documents.ts +104 -104
  24. package/app/types/domain.ts +5 -1
  25. package/app/types/execution.ts +18 -0
  26. package/app/types/github.ts +173 -173
  27. package/app/types/services.ts +27 -27
  28. package/app/types/tasks.ts +82 -82
  29. package/app/types/tracker.ts +27 -18
  30. package/app/utils/agentOutput.spec.ts +128 -128
  31. package/app/utils/agentOutput.ts +173 -173
  32. package/app/utils/observability.ts +52 -52
  33. 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
+ }
@@ -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 />