@cat-factory/app 0.37.1 → 0.37.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/app/components/auth/LoginScreen.vue +2 -2
- package/app/components/board/nodes/BlockNode.vue +32 -13
- package/app/components/bootstrap/BootstrapModal.vue +10 -6
- package/app/components/documents/DocumentImportModal.vue +11 -7
- package/app/components/github/AddServiceFromRepoModal.vue +9 -5
- package/app/components/github/GitHubPanel.vue +8 -4
- package/app/components/kaizen/KaizenPanel.vue +7 -3
- package/app/components/layout/AccountTeamSettings.vue +2 -3
- package/app/components/layout/IntegrationsHub.vue +2 -0
- package/app/components/panels/ObservabilityPanel.vue +12 -7
- package/app/components/providers/VendorCredentialsModal.vue +10 -6
- package/app/components/sandbox/SandboxPanel.vue +30 -19
- package/app/components/settings/IssueTrackerPanel.vue +3 -1
- package/app/components/settings/LocalModeSettingsPanel.vue +7 -3
- package/app/components/settings/LocalModelEndpointsPanel.vue +7 -3
- package/app/components/settings/ModelConfigurationPanel.vue +12 -8
- package/app/components/settings/ObservabilityConnectionPanel.vue +16 -12
- package/app/components/settings/OpenRouterCatalogPanel.vue +14 -9
- package/app/components/settings/ProviderConnectionPanel.vue +4 -4
- package/app/components/settings/UserSecretsSection.vue +7 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +3 -1
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/client.ts +11 -1
- package/app/composables/api/errors.spec.ts +53 -0
- package/app/composables/api/errors.ts +63 -0
- package/app/composables/useBlockQueries.ts +31 -9
- package/app/composables/usePipelineErrorToast.ts +5 -5
- package/app/composables/useSourceIntegration.ts +3 -3
- package/app/pages/index.vue +103 -51
- package/app/stores/board.spec.ts +30 -0
- package/app/stores/board.ts +27 -2
- package/app/stores/brainstorm.ts +11 -0
- package/app/stores/clarity.ts +11 -0
- package/app/stores/consensus.ts +7 -1
- package/app/stores/execution.spec.ts +43 -0
- package/app/stores/execution.ts +19 -0
- package/app/stores/github.ts +17 -0
- package/app/stores/personalSubscriptions.ts +2 -1
- package/app/stores/requirements.ts +12 -0
- package/app/stores/workspace.ts +17 -0
- package/package.json +2 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ApiError, apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
|
|
3
|
+
|
|
4
|
+
// The contract client (`sendByApiContract`) reports a declared non-2xx as a plain
|
|
5
|
+
// `{ statusCode, headers, body }` value — body under `.body`, NOT an Error. Before the
|
|
6
|
+
// `ApiError` wrap, every `instanceof Error` check rendered "[object Object]" and every
|
|
7
|
+
// `.data.error` reader (parseConflict / parseCredentialError / login + probe messages)
|
|
8
|
+
// silently returned nothing. These tests lock that in.
|
|
9
|
+
|
|
10
|
+
describe('ApiError', () => {
|
|
11
|
+
const body = { error: { code: 'conflict', message: 'Nope', details: { reason: 'task_limit' } } }
|
|
12
|
+
|
|
13
|
+
it('is a real Error carrying the server message, status, and body', () => {
|
|
14
|
+
const e = new ApiError(409, body)
|
|
15
|
+
expect(e).toBeInstanceOf(Error)
|
|
16
|
+
expect(e.message).toBe('Nope') // not "[object Object]"
|
|
17
|
+
expect(e.statusCode).toBe(409)
|
|
18
|
+
expect(e.envelope).toEqual(body.error)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('falls back to a status message when the body carries no envelope', () => {
|
|
22
|
+
expect(new ApiError(500, 'gateway down').message).toBe('Request failed (HTTP 500)')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('apiErrorEnvelope', () => {
|
|
27
|
+
const envelope = { code: 'credential_required', message: 'Unlock', details: { vendor: 'claude' } }
|
|
28
|
+
|
|
29
|
+
it('reads the envelope from a wrapped ApiError (contract client)', () => {
|
|
30
|
+
expect(apiErrorEnvelope(new ApiError(428, { error: envelope }))).toEqual(envelope)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reads the envelope from a bare { body } value (contract client, unwrapped)', () => {
|
|
34
|
+
expect(apiErrorEnvelope({ statusCode: 428, body: { error: envelope } })).toEqual(envelope)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reads the envelope from a legacy $fetch FetchError (body under .data)', () => {
|
|
38
|
+
expect(apiErrorEnvelope({ statusCode: 428, data: { error: envelope } })).toEqual(envelope)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns undefined for a network/non-API error', () => {
|
|
42
|
+
expect(apiErrorEnvelope(new Error('socket hang up'))).toBeUndefined()
|
|
43
|
+
expect(apiErrorEnvelope(undefined)).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('apiErrorStatus', () => {
|
|
48
|
+
it('reads .statusCode (contract client) and .status (legacy)', () => {
|
|
49
|
+
expect(apiErrorStatus(new ApiError(503, {}))).toBe(503)
|
|
50
|
+
expect(apiErrorStatus({ status: 500 })).toBe(500)
|
|
51
|
+
expect(apiErrorStatus(new Error('x'))).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failed API call, normalised to a real `Error`.
|
|
3
|
+
*
|
|
4
|
+
* The contract client (`sendByApiContract`) reports a contract-declared non-2xx as a
|
|
5
|
+
* plain `{ statusCode, headers, body }` value — NOT an `Error` — with the parsed
|
|
6
|
+
* `{ error: { code, message, details } }` envelope under `body`. Throwing that bare
|
|
7
|
+
* object breaks every `error instanceof Error` check (they fall to `String(error)` =
|
|
8
|
+
* `"[object Object]"`) and hides the server's message. `sendContract` wraps it in this
|
|
9
|
+
* class so call sites get `instanceof Error`, a real `.message` (the server's), the
|
|
10
|
+
* `.statusCode`, and the typed `.envelope`.
|
|
11
|
+
*/
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly statusCode: number
|
|
14
|
+
/** The parsed response body (the `{ error: {...} }` envelope for our controllers). */
|
|
15
|
+
readonly body: unknown
|
|
16
|
+
|
|
17
|
+
constructor(statusCode: number, body: unknown) {
|
|
18
|
+
super(envelopeOf(body)?.message ?? `Request failed (HTTP ${statusCode})`)
|
|
19
|
+
this.name = 'ApiError'
|
|
20
|
+
this.statusCode = statusCode
|
|
21
|
+
this.body = body
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `{ code, message, details, issues }` envelope, when the body carries one. */
|
|
25
|
+
get envelope(): ApiErrorEnvelope | undefined {
|
|
26
|
+
return envelopeOf(this.body)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The error envelope every controller emits (`handleError` / contract request-validator). */
|
|
31
|
+
export interface ApiErrorEnvelope {
|
|
32
|
+
code?: string
|
|
33
|
+
message?: string
|
|
34
|
+
details?: unknown
|
|
35
|
+
issues?: { path?: string; message: string }[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the `{ error: {...} }` envelope out of a parsed response body, else undefined. */
|
|
39
|
+
function envelopeOf(body: unknown): ApiErrorEnvelope | undefined {
|
|
40
|
+
if (!body || typeof body !== 'object') return undefined
|
|
41
|
+
const error = (body as { error?: unknown }).error
|
|
42
|
+
return error && typeof error === 'object' ? (error as ApiErrorEnvelope) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pull the server error envelope out of any thrown API error, regardless of which client
|
|
47
|
+
* produced it: the contract client (`ApiError`, body under `.body`) or the legacy `$fetch`
|
|
48
|
+
* path (ofetch `FetchError`, body under `.data`). Returns undefined for network faults or
|
|
49
|
+
* non-API errors.
|
|
50
|
+
*/
|
|
51
|
+
export function apiErrorEnvelope(error: unknown): ApiErrorEnvelope | undefined {
|
|
52
|
+
if (error instanceof ApiError) return error.envelope
|
|
53
|
+
const e = error as { body?: unknown; data?: unknown }
|
|
54
|
+
return envelopeOf(e?.body) ?? envelopeOf(e?.data)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The HTTP status of a thrown API error, when present (contract client or `$fetch`). */
|
|
58
|
+
export function apiErrorStatus(error: unknown): number | undefined {
|
|
59
|
+
const e = error as { statusCode?: unknown; status?: unknown }
|
|
60
|
+
if (typeof e?.statusCode === 'number') return e.statusCode
|
|
61
|
+
if (typeof e?.status === 'number') return e.status
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
@@ -9,14 +9,36 @@ import type { Block, BlockStatus } from '~/types/domain'
|
|
|
9
9
|
* them unchanged, so callers and tests are unaffected.
|
|
10
10
|
*/
|
|
11
11
|
export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Single-pass indexes rebuilt once per `blocks` change: id → block,
|
|
14
|
+
* parentId → children (insertion order), epicId → members. Every per-frame
|
|
15
|
+
* query reads these instead of re-scanning the whole array, so a streamed
|
|
16
|
+
* single-block upsert costs ~O(children touched) rather than O(frames × N).
|
|
17
|
+
*/
|
|
18
|
+
const index = computed(() => {
|
|
19
|
+
const byId = new Map<string, Block>()
|
|
20
|
+
const childrenByParent = new Map<string, Block[]>()
|
|
21
|
+
const membersByEpic = new Map<string, Block[]>()
|
|
22
|
+
for (const b of blocks.value) {
|
|
23
|
+
byId.set(b.id, b)
|
|
24
|
+
if (b.parentId) {
|
|
25
|
+
const siblings = childrenByParent.get(b.parentId)
|
|
26
|
+
if (siblings) siblings.push(b)
|
|
27
|
+
else childrenByParent.set(b.parentId, [b])
|
|
28
|
+
}
|
|
29
|
+
if (b.epicId) {
|
|
30
|
+
const members = membersByEpic.get(b.epicId)
|
|
31
|
+
if (members) members.push(b)
|
|
32
|
+
else membersByEpic.set(b.epicId, [b])
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { byId, childrenByParent, membersByEpic }
|
|
16
36
|
})
|
|
17
37
|
|
|
38
|
+
const byId = computed(() => index.value.byId)
|
|
39
|
+
|
|
18
40
|
function getBlock(id: string) {
|
|
19
|
-
return
|
|
41
|
+
return index.value.byId.get(id)
|
|
20
42
|
}
|
|
21
43
|
|
|
22
44
|
/** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
|
|
@@ -24,17 +46,17 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
24
46
|
|
|
25
47
|
/** Direct children of a block, in insertion order. */
|
|
26
48
|
function childrenOf(parentId: string) {
|
|
27
|
-
return
|
|
49
|
+
return index.value.childrenByParent.get(parentId) ?? []
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
/** Tasks directly inside a container (a service or a module). */
|
|
31
53
|
function tasksOf(containerId: string) {
|
|
32
|
-
return
|
|
54
|
+
return childrenOf(containerId).filter((b) => b.level === 'task')
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
/** Modules (sub-frames) inside a service. */
|
|
36
58
|
function modulesOf(serviceId: string) {
|
|
37
|
-
return
|
|
59
|
+
return childrenOf(serviceId).filter((b) => b.level === 'module')
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
/** Tasks anywhere under a container — directly, or nested inside its modules. */
|
|
@@ -61,7 +83,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
61
83
|
|
|
62
84
|
/** The tasks that belong to an epic (anywhere on the board) via their `epicId`. */
|
|
63
85
|
function epicMembers(epicId: string): Block[] {
|
|
64
|
-
return
|
|
86
|
+
return index.value.membersByEpic.get(epicId) ?? []
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
/** The epic a task belongs to, if any. */
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { apiErrorEnvelope } from './api/errors'
|
|
10
|
+
|
|
9
11
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
10
12
|
interface ConflictDetails {
|
|
11
13
|
reason?: string
|
|
@@ -13,15 +15,13 @@ interface ConflictDetails {
|
|
|
13
15
|
[key: string]: unknown
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
/** Pull a 409 conflict's `{ reason, message, details }` out of a thrown
|
|
18
|
+
/** Pull a 409 conflict's `{ reason, message, details }` out of a thrown API error, else null. */
|
|
17
19
|
export function parseConflict(
|
|
18
20
|
error: unknown,
|
|
19
21
|
): { reason?: string; message: string; details: ConflictDetails } | null {
|
|
20
|
-
const body = (
|
|
21
|
-
error as { data?: { error?: { code?: string; message?: string; details?: ConflictDetails } } }
|
|
22
|
-
)?.data?.error
|
|
22
|
+
const body = apiErrorEnvelope(error)
|
|
23
23
|
if (body?.code !== 'conflict') return null
|
|
24
|
-
const details = body.details ?? {}
|
|
24
|
+
const details = (body.details as ConflictDetails | undefined) ?? {}
|
|
25
25
|
return {
|
|
26
26
|
reason: typeof details.reason === 'string' ? details.reason : undefined,
|
|
27
27
|
message: body.message ?? 'This action conflicts with the current state.',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ComputedRef, type Ref, computed, ref } from 'vue'
|
|
2
|
+
import { apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
|
|
2
3
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -79,10 +80,9 @@ export function useSourceIntegration<
|
|
|
79
80
|
// reason so a panel can explain it (503 = off here; 500 = the backend errored, e.g. an
|
|
80
81
|
// unapplied migration).
|
|
81
82
|
available.value = false
|
|
82
|
-
const
|
|
83
|
-
const serverMessage = err?.data?.error?.message
|
|
83
|
+
const serverMessage = apiErrorEnvelope(e)?.message
|
|
84
84
|
probeError.value = {
|
|
85
|
-
status:
|
|
85
|
+
status: apiErrorStatus(e) ?? null,
|
|
86
86
|
message: serverMessage || (e instanceof Error ? e.message : String(e)),
|
|
87
87
|
}
|
|
88
88
|
sources.value = []
|
package/app/pages/index.vue
CHANGED
|
@@ -5,45 +5,93 @@ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
|
5
5
|
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
6
6
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
7
7
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
8
|
+
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
9
|
+
// Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
|
|
10
|
+
// store-driven so they must react from anywhere — kept eager for snappy open/close).
|
|
8
11
|
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
9
12
|
import InspectorPanel from '~/components/panels/InspectorPanel.vue'
|
|
10
13
|
import DecisionModal from '~/components/panels/DecisionModal.vue'
|
|
11
14
|
import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
|
|
12
15
|
import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
|
|
13
|
-
import ObservabilityPanel from '~/components/panels/ObservabilityPanel.vue'
|
|
14
|
-
import KaizenPanel from '~/components/kaizen/KaizenPanel.vue'
|
|
15
16
|
import BlockFocusView from '~/components/focus/BlockFocusView.vue'
|
|
16
|
-
import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
|
|
17
|
-
import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
|
|
18
|
-
import SpawnPreviewModal from '~/components/documents/SpawnPreviewModal.vue'
|
|
19
17
|
import TaskSourceConnectModal from '~/components/tasks/TaskSourceConnectModal.vue'
|
|
20
18
|
import TaskImportModal from '~/components/tasks/TaskImportModal.vue'
|
|
21
19
|
import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
22
20
|
import RecurringPipelineModal from '~/components/board/RecurringPipelineModal.vue'
|
|
23
|
-
import BootstrapModal from '~/components/bootstrap/BootstrapModal.vue'
|
|
24
|
-
import AddServiceFromRepoModal from '~/components/github/AddServiceFromRepoModal.vue'
|
|
25
|
-
import GitHubPanel from '~/components/github/GitHubPanel.vue'
|
|
26
|
-
import SlackPanel from '~/components/slack/SlackPanel.vue'
|
|
27
21
|
import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
28
|
-
import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
|
|
29
22
|
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
30
|
-
import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
|
|
31
|
-
import PersonalSetupModal from '~/components/layout/PersonalSetupModal.vue'
|
|
32
|
-
import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
|
|
33
|
-
import AccountSettingsPanel from '~/components/settings/AccountSettingsPanel.vue'
|
|
34
|
-
import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
|
|
35
|
-
import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPanel.vue'
|
|
36
|
-
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
37
|
-
import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
|
|
38
|
-
import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
|
|
39
|
-
import LocalModeSettingsPanel from '~/components/settings/LocalModeSettingsPanel.vue'
|
|
40
|
-
import SandboxPanel from '~/components/sandbox/SandboxPanel.vue'
|
|
41
|
-
import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
|
|
42
|
-
import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
|
|
43
|
-
import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
|
|
44
23
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
45
|
-
|
|
46
|
-
|
|
24
|
+
|
|
25
|
+
// Heavy, rarely-open panels — code-split into their own chunks via defineAsyncComponent
|
|
26
|
+
// and mounted only while their ui open-flag is set (the v-if gates in the template), so
|
|
27
|
+
// they stay out of the initial bundle and don't run setup/watchers while closed.
|
|
28
|
+
const ObservabilityPanel = defineAsyncComponent(
|
|
29
|
+
() => import('~/components/panels/ObservabilityPanel.vue'),
|
|
30
|
+
)
|
|
31
|
+
const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
|
|
32
|
+
const DocumentSourceConnectModal = defineAsyncComponent(
|
|
33
|
+
() => import('~/components/documents/DocumentSourceConnectModal.vue'),
|
|
34
|
+
)
|
|
35
|
+
const DocumentImportModal = defineAsyncComponent(
|
|
36
|
+
() => import('~/components/documents/DocumentImportModal.vue'),
|
|
37
|
+
)
|
|
38
|
+
const SpawnPreviewModal = defineAsyncComponent(
|
|
39
|
+
() => import('~/components/documents/SpawnPreviewModal.vue'),
|
|
40
|
+
)
|
|
41
|
+
const BootstrapModal = defineAsyncComponent(
|
|
42
|
+
() => import('~/components/bootstrap/BootstrapModal.vue'),
|
|
43
|
+
)
|
|
44
|
+
const AddServiceFromRepoModal = defineAsyncComponent(
|
|
45
|
+
() => import('~/components/github/AddServiceFromRepoModal.vue'),
|
|
46
|
+
)
|
|
47
|
+
const GitHubPanel = defineAsyncComponent(() => import('~/components/github/GitHubPanel.vue'))
|
|
48
|
+
const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPanel.vue'))
|
|
49
|
+
const FragmentLibraryPanel = defineAsyncComponent(
|
|
50
|
+
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
51
|
+
)
|
|
52
|
+
const IntegrationsHub = defineAsyncComponent(
|
|
53
|
+
() => import('~/components/layout/IntegrationsHub.vue'),
|
|
54
|
+
)
|
|
55
|
+
const PersonalSetupModal = defineAsyncComponent(
|
|
56
|
+
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
57
|
+
)
|
|
58
|
+
const WorkspaceSettingsPanel = defineAsyncComponent(
|
|
59
|
+
() => import('~/components/settings/WorkspaceSettingsPanel.vue'),
|
|
60
|
+
)
|
|
61
|
+
const AccountSettingsPanel = defineAsyncComponent(
|
|
62
|
+
() => import('~/components/settings/AccountSettingsPanel.vue'),
|
|
63
|
+
)
|
|
64
|
+
const ObservabilityConnectionPanel = defineAsyncComponent(
|
|
65
|
+
() => import('~/components/settings/ObservabilityConnectionPanel.vue'),
|
|
66
|
+
)
|
|
67
|
+
const ProviderConnectionPanel = defineAsyncComponent(
|
|
68
|
+
() => import('~/components/settings/ProviderConnectionPanel.vue'),
|
|
69
|
+
)
|
|
70
|
+
const ModelConfigurationPanel = defineAsyncComponent(
|
|
71
|
+
() => import('~/components/settings/ModelConfigurationPanel.vue'),
|
|
72
|
+
)
|
|
73
|
+
const LocalModelEndpointsPanel = defineAsyncComponent(
|
|
74
|
+
() => import('~/components/settings/LocalModelEndpointsPanel.vue'),
|
|
75
|
+
)
|
|
76
|
+
const LocalModeSettingsPanel = defineAsyncComponent(
|
|
77
|
+
() => import('~/components/settings/LocalModeSettingsPanel.vue'),
|
|
78
|
+
)
|
|
79
|
+
const SandboxPanel = defineAsyncComponent(() => import('~/components/sandbox/SandboxPanel.vue'))
|
|
80
|
+
const UserSecretsSection = defineAsyncComponent(
|
|
81
|
+
() => import('~/components/settings/UserSecretsSection.vue'),
|
|
82
|
+
)
|
|
83
|
+
const OpenRouterCatalogPanel = defineAsyncComponent(
|
|
84
|
+
() => import('~/components/settings/OpenRouterCatalogPanel.vue'),
|
|
85
|
+
)
|
|
86
|
+
const VendorCredentialsModal = defineAsyncComponent(
|
|
87
|
+
() => import('~/components/providers/VendorCredentialsModal.vue'),
|
|
88
|
+
)
|
|
89
|
+
const AiProviderOnboardingModal = defineAsyncComponent(
|
|
90
|
+
() => import('~/components/providers/AiProviderOnboardingModal.vue'),
|
|
91
|
+
)
|
|
92
|
+
const AiPresetMismatchDialog = defineAsyncComponent(
|
|
93
|
+
() => import('~/components/providers/AiPresetMismatchDialog.vue'),
|
|
94
|
+
)
|
|
47
95
|
|
|
48
96
|
const workspace = useWorkspaceStore()
|
|
49
97
|
const github = useGitHubStore()
|
|
@@ -170,41 +218,45 @@ watch(
|
|
|
170
218
|
<BlockFocusView />
|
|
171
219
|
</main>
|
|
172
220
|
|
|
221
|
+
<!-- Always-mounted, fast-path surfaces. -->
|
|
173
222
|
<PipelineBuilder />
|
|
174
223
|
<DecisionModal />
|
|
175
224
|
<AgentStepDetail />
|
|
176
225
|
<StepResultViewHost />
|
|
177
|
-
<ObservabilityPanel />
|
|
178
|
-
<KaizenPanel />
|
|
179
|
-
<DocumentSourceConnectModal />
|
|
180
|
-
<DocumentImportModal />
|
|
181
|
-
<SpawnPreviewModal />
|
|
182
226
|
<TaskSourceConnectModal />
|
|
183
227
|
<TaskImportModal />
|
|
184
228
|
<AddTaskModal />
|
|
185
229
|
<RecurringPipelineModal />
|
|
186
|
-
<BootstrapModal />
|
|
187
|
-
<AddServiceFromRepoModal />
|
|
188
|
-
<GitHubPanel />
|
|
189
|
-
<SlackPanel />
|
|
190
|
-
<FragmentLibraryPanel />
|
|
191
230
|
<CommandBar />
|
|
192
|
-
<IntegrationsHub />
|
|
193
|
-
<PersonalSetupModal />
|
|
194
|
-
<WorkspaceSettingsPanel />
|
|
195
|
-
<AccountSettingsPanel />
|
|
196
|
-
<ObservabilityConnectionPanel />
|
|
197
|
-
<ProviderConnectionPanel />
|
|
198
|
-
<ModelConfigurationPanel />
|
|
199
|
-
<LocalModelEndpointsPanel />
|
|
200
|
-
<LocalModeSettingsPanel />
|
|
201
|
-
<SandboxPanel />
|
|
202
|
-
<UserSecretsSection />
|
|
203
|
-
<OpenRouterCatalogPanel />
|
|
204
|
-
<VendorCredentialsModal />
|
|
205
231
|
<PersonalCredentialModal />
|
|
206
|
-
|
|
207
|
-
|
|
232
|
+
|
|
233
|
+
<!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
|
|
234
|
+
first open (its own chunk) rather than bloating the initial bundle. -->
|
|
235
|
+
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
236
|
+
<KaizenPanel v-if="ui.kaizenScreenOpen" />
|
|
237
|
+
<DocumentSourceConnectModal v-if="ui.documentConnect" />
|
|
238
|
+
<DocumentImportModal v-if="ui.documentImport" />
|
|
239
|
+
<SpawnPreviewModal v-if="ui.spawnPreview" />
|
|
240
|
+
<BootstrapModal v-if="ui.bootstrapOpen" />
|
|
241
|
+
<AddServiceFromRepoModal v-if="ui.addServiceOpen" />
|
|
242
|
+
<GitHubPanel v-if="ui.githubOpen" />
|
|
243
|
+
<SlackPanel v-if="ui.slackOpen" />
|
|
244
|
+
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
245
|
+
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
246
|
+
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
247
|
+
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
248
|
+
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
249
|
+
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
250
|
+
<ProviderConnectionPanel v-if="ui.providerConnectionKind" />
|
|
251
|
+
<ModelConfigurationPanel v-if="ui.modelConfigOpen" />
|
|
252
|
+
<LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
|
|
253
|
+
<LocalModeSettingsPanel v-if="ui.localModeSettingsOpen" />
|
|
254
|
+
<SandboxPanel v-if="ui.sandboxOpen" />
|
|
255
|
+
<UserSecretsSection v-if="ui.userSecretsOpen" />
|
|
256
|
+
<OpenRouterCatalogPanel v-if="ui.openRouterOpen" />
|
|
257
|
+
<VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
|
|
258
|
+
<AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
|
|
259
|
+
<AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
|
|
208
260
|
</template>
|
|
209
261
|
|
|
210
262
|
<!-- Backend unreachable / bootstrap failed -->
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -87,6 +87,36 @@ describe('board store read getters', () => {
|
|
|
87
87
|
).toEqual(['t2', 't3'])
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
it('epicMembers groups blocks by their epicId (indexed lookup)', () => {
|
|
91
|
+
store.hydrate([
|
|
92
|
+
frame('f1'),
|
|
93
|
+
block('e1', { level: 'epic' }),
|
|
94
|
+
task('t1', 'f1', { epicId: 'e1' }),
|
|
95
|
+
task('t2', 'f1', { epicId: 'e1' }),
|
|
96
|
+
task('t3', 'f1'),
|
|
97
|
+
])
|
|
98
|
+
expect(
|
|
99
|
+
store
|
|
100
|
+
.epicMembers('e1')
|
|
101
|
+
.map((b) => b.id)
|
|
102
|
+
.sort(),
|
|
103
|
+
).toEqual(['t1', 't2'])
|
|
104
|
+
expect(store.epicMembers('none')).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('hydrate reuses the existing object for an unchanged block (stable identity)', () => {
|
|
108
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'a' })])
|
|
109
|
+
const before = store.getBlock('t1')
|
|
110
|
+
// Re-hydrate with an equal-but-distinct snapshot: identity is preserved so unchanged
|
|
111
|
+
// blocks don't force a re-render on a coarse full refresh.
|
|
112
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'a' })])
|
|
113
|
+
expect(store.getBlock('t1')).toBe(before)
|
|
114
|
+
// A block whose content changed gets the fresh object.
|
|
115
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'b' })])
|
|
116
|
+
expect(store.getBlock('t1')).not.toBe(before)
|
|
117
|
+
expect(store.getBlock('t1')?.title).toBe('b')
|
|
118
|
+
})
|
|
119
|
+
|
|
90
120
|
it('serviceOf walks up to the owning top-level frame', () => {
|
|
91
121
|
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1'), task('t2', 'f1')])
|
|
92
122
|
expect(store.serviceOf(store.getBlock('t1')!)?.id).toBe('f1')
|
package/app/stores/board.ts
CHANGED
|
@@ -29,9 +29,34 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
29
29
|
const queries = useBlockQueries(blocks)
|
|
30
30
|
const { getBlock } = queries
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile the cached blocks against a server snapshot, reusing the existing
|
|
34
|
+
* object for any block whose content is unchanged. The server stays authoritative
|
|
35
|
+
* (it replaces optimistic edits and drops deleted blocks), but an unchanged block
|
|
36
|
+
* keeps its identity, so a coarse full-refresh doesn't hand every frame/task a new
|
|
37
|
+
* object reference and force the whole board to re-render — only genuinely changed
|
|
38
|
+
* blocks invalidate. Blocks are emitted in a stable order by the backend mapper, so
|
|
39
|
+
* a per-block JSON compare is a reliable, cheap (refresh is debounced) equality check.
|
|
40
|
+
*/
|
|
41
|
+
// Per-object serialization cache, keyed by block identity so it self-invalidates: a
|
|
42
|
+
// block we keep (same reference) stays cached, while a fresh/`upsert`ed object isn't in
|
|
43
|
+
// the map and is re-serialized. Lets a hydrate stringify each kept block once (the
|
|
44
|
+
// incoming snapshot) rather than twice (existing + incoming).
|
|
45
|
+
const serialized = new WeakMap<Block, string>()
|
|
46
|
+
function jsonFor(b: Block): string {
|
|
47
|
+
let s = serialized.get(b)
|
|
48
|
+
if (s === undefined) {
|
|
49
|
+
s = JSON.stringify(b)
|
|
50
|
+
serialized.set(b, s)
|
|
51
|
+
}
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
33
54
|
function hydrate(next: Block[]) {
|
|
34
|
-
blocks.value
|
|
55
|
+
const prev = new Map(blocks.value.map((b) => [b.id, b]))
|
|
56
|
+
blocks.value = next.map((n) => {
|
|
57
|
+
const existing = prev.get(n.id)
|
|
58
|
+
return existing && jsonFor(existing) === jsonFor(n) ? existing : n
|
|
59
|
+
})
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
/** Insert or replace a block returned by the backend. */
|
package/app/stores/brainstorm.ts
CHANGED
|
@@ -84,6 +84,16 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
84
84
|
sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Drop all cached sessions + in-flight state (called on workspace switch). */
|
|
88
|
+
function reset() {
|
|
89
|
+
available.value = null
|
|
90
|
+
sessions.value = {}
|
|
91
|
+
running.value = new Set()
|
|
92
|
+
incorporating.value = new Set()
|
|
93
|
+
loadingByKey.value = new Set()
|
|
94
|
+
inFlight.clear()
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
function withFlag(set: typeof running, k: string, on: boolean) {
|
|
88
98
|
const next = new Set(set.value)
|
|
89
99
|
if (on) next.add(k)
|
|
@@ -204,6 +214,7 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
204
214
|
reReview,
|
|
205
215
|
proceed,
|
|
206
216
|
resolveExceeded,
|
|
217
|
+
reset,
|
|
207
218
|
// Patch the cache from a live `brainstorm` stream event.
|
|
208
219
|
upsert: store,
|
|
209
220
|
}
|
package/app/stores/clarity.ts
CHANGED
|
@@ -87,6 +87,16 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
87
87
|
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Drop all cached reviews + in-flight state (called on workspace switch). */
|
|
91
|
+
function reset() {
|
|
92
|
+
available.value = null
|
|
93
|
+
reviews.value = {}
|
|
94
|
+
reviewing.value = new Set()
|
|
95
|
+
incorporating.value = new Set()
|
|
96
|
+
loadingByBlock.value = new Set()
|
|
97
|
+
inFlight.clear()
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
function withFlag(set: typeof reviewing, key: string, on: boolean) {
|
|
91
101
|
const next = new Set(set.value)
|
|
92
102
|
if (on) next.add(key)
|
|
@@ -194,6 +204,7 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
194
204
|
reReview,
|
|
195
205
|
proceed,
|
|
196
206
|
resolveExceeded,
|
|
207
|
+
reset,
|
|
197
208
|
// Patch the cache from a live `clarity` stream event.
|
|
198
209
|
upsert: store,
|
|
199
210
|
}
|
package/app/stores/consensus.ts
CHANGED
|
@@ -56,5 +56,11 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/** Drop all cached sessions + in-flight state (called on workspace switch). */
|
|
60
|
+
function reset() {
|
|
61
|
+
sessions.value = {}
|
|
62
|
+
loading.value = new Set()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { sessions, sessionFor, isLoading, load, upsert, reset }
|
|
60
66
|
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
3
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal instance shape — the `decisionsByBlock` / `approvalsByBlock` getters only read
|
|
7
|
+
* `id`, `blockId` and each step's `{ decision, approval, agentKind }`, so a cast keeps the
|
|
8
|
+
* fixtures focused on the grouping behaviour rather than the full wire contract.
|
|
9
|
+
*/
|
|
10
|
+
function instance(id: string, blockId: string, steps: unknown[]): ExecutionInstance {
|
|
11
|
+
return { id, blockId, steps } as unknown as ExecutionInstance
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('execution store gate grouping', () => {
|
|
15
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
store = useExecutionStore()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('decisionsByBlock groups open (unchosen) decisions by block', () => {
|
|
21
|
+
store.hydrate([
|
|
22
|
+
instance('e1', 'b1', [
|
|
23
|
+
{ agentKind: 'coder', decision: { id: 'd1', chosen: null } },
|
|
24
|
+
{ agentKind: 'coder', decision: { id: 'd2', chosen: 'yes' } }, // chosen ⇒ excluded
|
|
25
|
+
]),
|
|
26
|
+
instance('e2', 'b2', [{ agentKind: 'architect', decision: { id: 'd3', chosen: null } }]),
|
|
27
|
+
])
|
|
28
|
+
expect(store.decisionsByBlock.get('b1')?.map((d) => d.decision.id)).toEqual(['d1'])
|
|
29
|
+
expect(store.decisionsByBlock.get('b2')?.map((d) => d.decision.id)).toEqual(['d3'])
|
|
30
|
+
expect(store.decisionsByBlock.has('missing')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('approvalsByBlock groups pending approvals by block', () => {
|
|
34
|
+
store.hydrate([
|
|
35
|
+
instance('e1', 'b1', [
|
|
36
|
+
{ agentKind: 'merger', approval: { id: 'a1', status: 'pending' } },
|
|
37
|
+
{ agentKind: 'merger', approval: { id: 'a2', status: 'approved' } }, // not pending ⇒ excluded
|
|
38
|
+
]),
|
|
39
|
+
])
|
|
40
|
+
expect(store.approvalsByBlock.get('b1')?.map((a) => a.approval.id)).toEqual(['a1'])
|
|
41
|
+
expect(store.approvalsByBlock.get('b2')).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -106,6 +106,23 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
106
106
|
return out
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Open decisions/approvals grouped by the block they belong to, so a board card
|
|
111
|
+
* resolves its own + its tasks' pending gates with O(1) lookups instead of
|
|
112
|
+
* re-filtering the global lists once per frame on every execution event.
|
|
113
|
+
*/
|
|
114
|
+
function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
|
|
115
|
+
const map = new Map<string, T[]>()
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
const list = map.get(item.blockId)
|
|
118
|
+
if (list) list.push(item)
|
|
119
|
+
else map.set(item.blockId, [item])
|
|
120
|
+
}
|
|
121
|
+
return map
|
|
122
|
+
}
|
|
123
|
+
const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
|
|
124
|
+
const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
|
|
125
|
+
|
|
109
126
|
/**
|
|
110
127
|
* Start `pipeline` against a block; the server marks the block in-progress. A block
|
|
111
128
|
* pinned to an individual-usage model (Claude) needs the initiator's personal
|
|
@@ -279,6 +296,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
279
296
|
pendingDecisionCount,
|
|
280
297
|
openDecisions,
|
|
281
298
|
openApprovals,
|
|
299
|
+
decisionsByBlock,
|
|
300
|
+
approvalsByBlock,
|
|
282
301
|
pendingApprovalCount,
|
|
283
302
|
start,
|
|
284
303
|
resolveDecision,
|