@cat-factory/app 0.36.0 → 0.37.1

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 (88) hide show
  1. package/app/components/auth/UserMenu.vue +11 -1
  2. package/app/components/brainstorm/BrainstormWindow.vue +2 -1
  3. package/app/components/clarity/ClarityReviewWindow.vue +2 -1
  4. package/app/components/layout/IntegrationBackTitle.vue +12 -7
  5. package/app/components/layout/IntegrationsHub.vue +191 -43
  6. package/app/components/layout/PersonalSetupModal.vue +141 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  8. package/app/components/providers/VendorCredentialsModal.vue +7 -2
  9. package/app/composables/api/accounts.ts +36 -51
  10. package/app/composables/api/auth.ts +20 -19
  11. package/app/composables/api/board.ts +60 -40
  12. package/app/composables/api/bootstrap.ts +25 -22
  13. package/app/composables/api/client.ts +102 -0
  14. package/app/composables/api/context.ts +25 -6
  15. package/app/composables/api/documents.ts +36 -34
  16. package/app/composables/api/execution.ts +65 -48
  17. package/app/composables/api/followUps.ts +26 -26
  18. package/app/composables/api/fragments.ts +47 -34
  19. package/app/composables/api/github.ts +65 -45
  20. package/app/composables/api/humanReview.ts +7 -6
  21. package/app/composables/api/humanTest.ts +15 -11
  22. package/app/composables/api/kaizen.ts +8 -6
  23. package/app/composables/api/localSettings.ts +5 -4
  24. package/app/composables/api/models.ts +58 -51
  25. package/app/composables/api/notifications.ts +13 -7
  26. package/app/composables/api/presets.ts +34 -28
  27. package/app/composables/api/providerConnections.ts +68 -26
  28. package/app/composables/api/recurring.ts +40 -30
  29. package/app/composables/api/releaseHealth.ts +28 -26
  30. package/app/composables/api/reviews.ts +136 -114
  31. package/app/composables/api/sandbox.ts +52 -34
  32. package/app/composables/api/slack.ts +22 -25
  33. package/app/composables/api/spec.ts +3 -3
  34. package/app/composables/api/tasks.ts +42 -41
  35. package/app/composables/api/userSecrets.ts +12 -17
  36. package/app/composables/api/workspaces.ts +21 -15
  37. package/app/composables/useApi.ts +9 -1
  38. package/app/composables/useIntegrationBack.ts +9 -3
  39. package/app/composables/useSourceIntegration.ts +107 -0
  40. package/app/composables/useUpsertList.spec.ts +60 -0
  41. package/app/composables/useUpsertList.ts +57 -0
  42. package/app/pages/index.vue +2 -0
  43. package/app/stores/auth.ts +2 -1
  44. package/app/stores/board.ts +2 -1
  45. package/app/stores/brainstorm.ts +2 -2
  46. package/app/stores/clarity.ts +6 -2
  47. package/app/stores/documents.ts +27 -62
  48. package/app/stores/execution.ts +3 -2
  49. package/app/stores/github.ts +1 -2
  50. package/app/stores/mergePresets.ts +2 -6
  51. package/app/stores/notifications.ts +9 -6
  52. package/app/stores/pipelines.ts +1 -1
  53. package/app/stores/recurringPipelines.ts +2 -7
  54. package/app/stores/sandbox.ts +1 -2
  55. package/app/stores/tasks.ts +25 -76
  56. package/app/stores/ui.ts +62 -19
  57. package/app/types/accountSettings.ts +11 -36
  58. package/app/types/accounts.ts +16 -71
  59. package/app/types/bootstrap.ts +13 -75
  60. package/app/types/brainstorm.ts +13 -38
  61. package/app/types/clarity.ts +12 -43
  62. package/app/types/consensus.ts +16 -89
  63. package/app/types/documents.ts +19 -94
  64. package/app/types/domain.ts +54 -586
  65. package/app/types/execution.ts +48 -515
  66. package/app/types/fragments.ts +15 -83
  67. package/app/types/github.ts +25 -161
  68. package/app/types/incidentEnrichment.ts +10 -25
  69. package/app/types/localModels.ts +11 -61
  70. package/app/types/localSettings.ts +9 -26
  71. package/app/types/merge.ts +10 -68
  72. package/app/types/model-presets.ts +7 -28
  73. package/app/types/models.ts +16 -164
  74. package/app/types/notifications.ts +18 -77
  75. package/app/types/openrouter.ts +8 -34
  76. package/app/types/providerConnections.ts +21 -41
  77. package/app/types/provisioningLogs.ts +9 -29
  78. package/app/types/recurring.ts +10 -63
  79. package/app/types/releaseHealth.ts +15 -39
  80. package/app/types/requirements.ts +14 -84
  81. package/app/types/sandbox.ts +45 -161
  82. package/app/types/services.ts +3 -22
  83. package/app/types/slack.ts +10 -47
  84. package/app/types/spec.ts +15 -68
  85. package/app/types/tasks.ts +15 -111
  86. package/app/types/tracker.ts +9 -24
  87. package/app/types/userSecrets.ts +12 -47
  88. package/package.json +9 -2
@@ -1,61 +1,69 @@
1
- import type {
2
- Block,
3
- SourceTask,
4
- TaskConnection,
5
- TaskSearchResult,
6
- TaskSourceDiagnostic,
7
- TaskSourceKind,
8
- TaskSourceState,
9
- } from '~/types/domain'
10
- import type { PutTrackerSettingsInput, TrackerSettings } from '~/types/tracker'
1
+ import {
2
+ connectTaskSourceContract,
3
+ createTaskFromIssueContract,
4
+ diagnoseTaskSourceContract,
5
+ disconnectTaskSourceContract,
6
+ getTrackerSettingsContract,
7
+ importTaskContract,
8
+ linkTaskContract,
9
+ listTaskConnectionsContract,
10
+ listTaskSourcesContract,
11
+ listTasksContract,
12
+ putTrackerSettingsContract,
13
+ searchTasksContract,
14
+ setTaskSourceEnabledContract,
15
+ spawnEpicContract,
16
+ } from '@cat-factory/contracts'
17
+ import type { TaskSourceKind } from '~/types/domain'
18
+ import type { PutTrackerSettingsInput } from '~/types/tracker'
11
19
  import type { ApiContext } from './context'
12
20
 
13
21
  /** Task sources (Jira, …): connect/import/search/link + the workspace tracker selection. */
14
- export function tasksApi({ http, ws }: ApiContext) {
22
+ export function tasksApi({ send, ws }: ApiContext) {
15
23
  return {
16
24
  // ---- task sources (Jira, …) ------------------------------------------
17
25
  // The configured trackers + their connect/import metadata + the workspace's
18
26
  // per-source state (available + enabled). A 503 means the integration is off
19
27
  // (the store hides its UI on any error here).
20
28
  listTaskSources: (workspaceId: string) =>
21
- http<{ sources: TaskSourceState[] }>(`${ws(workspaceId)}/task-sources`),
29
+ send(listTaskSourcesContract, { pathPrefix: ws(workspaceId) }),
22
30
 
23
31
  setTaskSourceEnabled: (workspaceId: string, source: TaskSourceKind, enabled: boolean) =>
24
- http(`${ws(workspaceId)}/task-sources/${source}/enabled`, {
25
- method: 'PUT',
32
+ send(setTaskSourceEnabledContract, {
33
+ pathPrefix: ws(workspaceId),
34
+ pathParams: { source },
26
35
  body: { enabled },
27
36
  }),
28
37
 
29
38
  listTaskConnections: (workspaceId: string) =>
30
- http<{ connections: TaskConnection[] }>(`${ws(workspaceId)}/task-sources/connections`),
39
+ send(listTaskConnectionsContract, { pathPrefix: ws(workspaceId) }),
31
40
 
32
41
  connectTaskSource: (
33
42
  workspaceId: string,
34
43
  source: TaskSourceKind,
35
44
  credentials: Record<string, string>,
36
45
  ) =>
37
- http<TaskConnection>(`${ws(workspaceId)}/task-sources/${source}/connect`, {
38
- method: 'POST',
46
+ send(connectTaskSourceContract, {
47
+ pathPrefix: ws(workspaceId),
48
+ pathParams: { source },
39
49
  body: { credentials },
40
50
  }),
41
51
 
42
52
  disconnectTaskSource: (workspaceId: string, source: TaskSourceKind) =>
43
- http(`${ws(workspaceId)}/task-sources/${source}/connection`, { method: 'DELETE' }),
53
+ send(disconnectTaskSourceContract, {
54
+ pathPrefix: ws(workspaceId),
55
+ pathParams: { source },
56
+ }),
44
57
 
45
58
  // Live "check setup" probe: authenticates against the source and reads a slice
46
59
  // of its issues API, returning a classified verdict the panel renders verbatim.
47
60
  checkTaskSource: (workspaceId: string, source: TaskSourceKind) =>
48
- http<TaskSourceDiagnostic>(`${ws(workspaceId)}/task-sources/${source}/diagnostics`, {
49
- method: 'POST',
50
- }),
61
+ send(diagnoseTaskSourceContract, { pathPrefix: ws(workspaceId), pathParams: { source } }),
51
62
 
52
- listTasks: (workspaceId: string) => http<SourceTask[]>(`${ws(workspaceId)}/tasks`),
63
+ listTasks: (workspaceId: string) => send(listTasksContract, { pathPrefix: ws(workspaceId) }),
53
64
 
54
65
  importTask: (workspaceId: string, source: TaskSourceKind, body: { ref: string }) =>
55
- http<SourceTask>(`${ws(workspaceId)}/task-sources/${source}/import`, {
56
- method: 'POST',
57
- body,
58
- }),
66
+ send(importTaskContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
59
67
 
60
68
  searchTaskSource: (
61
69
  workspaceId: string,
@@ -63,24 +71,21 @@ export function tasksApi({ http, ws }: ApiContext) {
63
71
  query: string,
64
72
  blockId?: string,
65
73
  ) =>
66
- http<{ results: TaskSearchResult[] }>(`${ws(workspaceId)}/task-sources/${source}/search`, {
67
- method: 'POST',
74
+ send(searchTasksContract, {
75
+ pathPrefix: ws(workspaceId),
76
+ pathParams: { source },
68
77
  body: { query, ...(blockId ? { blockId } : {}) },
69
78
  }),
70
79
 
71
80
  linkTask: (
72
81
  workspaceId: string,
73
82
  body: { source: TaskSourceKind; externalId: string; blockId: string },
74
- ) => http<SourceTask>(`${ws(workspaceId)}/tasks/link`, { method: 'POST', body }),
83
+ ) => send(linkTaskContract, { pathPrefix: ws(workspaceId), body }),
75
84
 
76
85
  createTaskFromIssue: (
77
86
  workspaceId: string,
78
87
  body: { source: TaskSourceKind; externalId: string; containerId: string },
79
- ) =>
80
- http<{ block: Block; task: SourceTask }>(`${ws(workspaceId)}/tasks/create-block`, {
81
- method: 'POST',
82
- body,
83
- }),
88
+ ) => send(createTaskFromIssueContract, { pathPrefix: ws(workspaceId), body }),
84
89
 
85
90
  // Spawn an epic + its children as an epic node + child tasks, with dependency edges
86
91
  // seeded from the issues' blocked-by/depends-on links.
@@ -88,17 +93,13 @@ export function tasksApi({ http, ws }: ApiContext) {
88
93
  workspaceId: string,
89
94
  source: TaskSourceKind,
90
95
  body: { ref: string; containerId: string; position?: { x: number; y: number } },
91
- ) =>
92
- http<{ epic: Block; tasks: Block[] }>(
93
- `${ws(workspaceId)}/task-sources/${source}/epics/spawn`,
94
- { method: 'POST', body },
95
- ),
96
+ ) => send(spawnEpicContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
96
97
 
97
98
  // ---- issue-tracker selection (workspace-level) ------------------------
98
99
  getTrackerSettings: (workspaceId: string) =>
99
- http<TrackerSettings>(`${ws(workspaceId)}/tracker-settings`),
100
+ send(getTrackerSettingsContract, { pathPrefix: ws(workspaceId) }),
100
101
 
101
102
  putTrackerSettings: (workspaceId: string, body: PutTrackerSettingsInput) =>
102
- http<TrackerSettings>(`${ws(workspaceId)}/tracker-settings`, { method: 'PUT', body }),
103
+ send(putTrackerSettingsContract, { pathPrefix: ws(workspaceId), body }),
103
104
  }
104
105
  }
@@ -1,31 +1,26 @@
1
- import type {
2
- ConnectionTestResult,
3
- StoreUserSecretInput,
4
- TestUserSecretInput,
5
- UserSecretDescriptor,
6
- UserSecretKind,
7
- UserSecretStatus,
8
- } from '~/types/userSecrets'
1
+ import {
2
+ listUserSecretsContract,
3
+ removeUserSecretContract,
4
+ storeUserSecretContract,
5
+ testUserSecretContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { StoreUserSecretInput, TestUserSecretInput, UserSecretKind } from '~/types/userSecrets'
9
8
  import type { ApiContext } from './context'
10
9
 
11
10
  // Per-USER generic secrets (a GitHub PAT today). User-scoped (no workspace); the secret
12
11
  // is write-only server-side and never returned — only status metadata + a `hasSecret`
13
12
  // flag. `descriptors` drive the generic connect form; `test` probes before save.
14
- export function userSecretsApi({ http }: ApiContext) {
13
+ export function userSecretsApi({ send }: ApiContext) {
15
14
  return {
16
- listUserSecrets: () =>
17
- http<{ secrets: UserSecretStatus[]; descriptors: UserSecretDescriptor[] }>('/user-secrets'),
15
+ listUserSecrets: () => send(listUserSecretsContract, {}),
18
16
 
19
17
  storeUserSecret: (kind: UserSecretKind, body: StoreUserSecretInput) =>
20
- http<UserSecretStatus>(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'POST', body }),
18
+ send(storeUserSecretContract, { pathParams: { kind }, body }),
21
19
 
22
20
  deleteUserSecret: (kind: UserSecretKind) =>
23
- http(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'DELETE' }),
21
+ send(removeUserSecretContract, { pathParams: { kind } }),
24
22
 
25
23
  testUserSecret: (kind: UserSecretKind, body: TestUserSecretInput) =>
26
- http<ConnectionTestResult>(`/user-secrets/${encodeURIComponent(kind)}/test`, {
27
- method: 'POST',
28
- body,
29
- }),
24
+ send(testUserSecretContract, { pathParams: { kind }, body }),
30
25
  }
31
26
  }
@@ -1,36 +1,42 @@
1
- import type {
2
- UpdateWorkspaceSettingsInput,
3
- Workspace,
4
- WorkspaceSettings,
5
- WorkspaceSnapshot,
6
- } from '~/types/domain'
1
+ import {
2
+ createWorkspaceContract,
3
+ deleteWorkspaceContract,
4
+ getWorkspaceContract,
5
+ getWorkspaceSettingsContract,
6
+ listWorkspacesContract,
7
+ updateWorkspaceContract,
8
+ updateWorkspaceSettingsContract,
9
+ } from '@cat-factory/contracts'
10
+ import type { UpdateWorkspaceSettingsInput } from '~/types/domain'
7
11
  import type { ApiContext } from './context'
8
12
 
9
13
  /** Workspace CRUD + the full snapshot read. */
10
- export function workspacesApi({ http, ws }: ApiContext) {
14
+ export function workspacesApi({ send, ws }: ApiContext) {
11
15
  return {
12
16
  // ---- workspaces -------------------------------------------------------
13
- listWorkspaces: () => http<Workspace[]>('/workspaces'),
17
+ listWorkspaces: () => send(listWorkspacesContract, {}),
14
18
 
15
19
  createWorkspace: (
16
20
  body: { name?: string; description?: string; seed?: boolean; accountId?: string } = {},
17
- ) => http<WorkspaceSnapshot>('/workspaces', { method: 'POST', body }),
21
+ ) => send(createWorkspaceContract, { body }),
18
22
 
19
- getWorkspace: (workspaceId: string) => http<WorkspaceSnapshot>(ws(workspaceId)),
23
+ getWorkspace: (workspaceId: string) =>
24
+ send(getWorkspaceContract, { pathParams: { workspaceId } }),
20
25
 
21
26
  updateWorkspace: (workspaceId: string, body: { name?: string; description?: string | null }) =>
22
- http<Workspace>(ws(workspaceId), { method: 'PATCH', body }),
27
+ send(updateWorkspaceContract, { pathParams: { workspaceId }, body }),
23
28
 
24
29
  renameWorkspace: (workspaceId: string, name: string) =>
25
- http<Workspace>(ws(workspaceId), { method: 'PATCH', body: { name } }),
30
+ send(updateWorkspaceContract, { pathParams: { workspaceId }, body: { name } }),
26
31
 
27
- deleteWorkspace: (workspaceId: string) => http(ws(workspaceId), { method: 'DELETE' }),
32
+ deleteWorkspace: (workspaceId: string) =>
33
+ send(deleteWorkspaceContract, { pathParams: { workspaceId } }),
28
34
 
29
35
  // ---- workspace runtime settings (human-wait escalation + per-service task limit) --
30
36
  getWorkspaceSettings: (workspaceId: string) =>
31
- http<WorkspaceSettings>(`${ws(workspaceId)}/settings`),
37
+ send(getWorkspaceSettingsContract, { pathPrefix: ws(workspaceId) }),
32
38
 
33
39
  updateWorkspaceSettings: (workspaceId: string, body: UpdateWorkspaceSettingsInput) =>
34
- http<WorkspaceSettings>(`${ws(workspaceId)}/settings`, { method: 'PUT', body }),
40
+ send(updateWorkspaceSettingsContract, { pathPrefix: ws(workspaceId), body }),
35
41
  }
36
42
  }
@@ -1,4 +1,5 @@
1
1
  import type { FragmentOwnerKind } from '~/types/domain'
2
+ import { createApiClient, createSend, createSendWith } from './api/client'
2
3
  import type { ApiContext } from './api/context'
3
4
  import { accountsApi } from './api/accounts'
4
5
  import { authApi } from './api/auth'
@@ -75,7 +76,14 @@ export function useApi() {
75
76
  ? `/accounts/${encodeURIComponent(id)}`
76
77
  : `/workspaces/${encodeURIComponent(id)}`
77
78
 
78
- const ctx: ApiContext = { http, ws, scope, pwHeaders }
79
+ // The contract-driven client (wretch + sendByApiContract): one source of truth for
80
+ // path/method/request/response, shared with the backend via @cat-factory/contracts.
81
+ // API groups are migrated onto `send` incrementally; the rest still use `http`.
82
+ const client = createApiClient()
83
+ const send = createSend(client)
84
+ const sendWith = createSendWith(client)
85
+
86
+ const ctx: ApiContext = { http, client, send, sendWith, ws, scope, pwHeaders }
79
87
 
80
88
  return {
81
89
  ...authApi(ctx),
@@ -1,20 +1,26 @@
1
1
  import type { Ref, WritableComputedRef } from 'vue'
2
2
 
3
3
  /**
4
- * The shared "back to Integrations" handler for an integration sub-panel's modal header.
5
- * Every panel reached from the Integrations hub closes itself and reopens the hub when its
4
+ * The shared "back to the hub" handler for an integration sub-panel's modal header.
5
+ * Every panel reached from a hub closes itself and reopens that hub when its
6
6
  * {@link IntegrationBackTitle} Back control fires `@back`. Centralising it keeps the ~13
7
7
  * panels from each re-implementing the two-step close-then-reopen inline — which also
8
8
  * dodges a Vue SFC-compiler trap: a multi-statement inline template handler
9
9
  * (`open = false` ⏎ `ui.openIntegrations()`) is rejected at build time, so callers had to
10
10
  * resort to an obscure comma-operator expression. A named handler reads clearly instead.
11
11
  *
12
+ * Returns to whichever hub the panel was reached from: the user-scoped "My setup" hub when
13
+ * `cameFromPersonal` is set, else the workspace Integrations hub. A shared panel (e.g. the
14
+ * vendor-credentials modal, reachable from both) thus lands the user back where they were.
15
+ *
12
16
  * Pass the panel's `open` model (the writable ref/computed bound to its `UModal`).
13
17
  */
14
18
  export function useIntegrationBack(open: Ref<boolean> | WritableComputedRef<boolean>) {
15
19
  const ui = useUiStore()
16
20
  return () => {
21
+ const toPersonal = ui.cameFromPersonal
17
22
  open.value = false
18
- ui.openIntegrations()
23
+ if (toPersonal) ui.openPersonalSetup()
24
+ else ui.openIntegrations()
19
25
  }
20
26
  }
@@ -0,0 +1,107 @@
1
+ import { type ComputedRef, type Ref, computed, ref } from 'vue'
2
+ import { useUpsertList } from '~/composables/useUpsertList'
3
+
4
+ /**
5
+ * The source-integration lifecycle shared by the document-source and task-source stores:
6
+ * the opt-in `available` gate, the per-source `connections` list, the `descriptorFor` /
7
+ * `connectionFor` / `isConnected` lookups, and a `probe()` that resolves all of it (hiding
8
+ * the UI when the integration is off). Both stores previously hand-rolled this, with
9
+ * inconsistent error handling — one captured the probe failure, the other swallowed it.
10
+ * Standardising here means every integration now records WHY a probe failed
11
+ * (`probeError`: a 503 "turned off on this deployment" vs a 500 "the backend errored"), so
12
+ * a settings panel can explain the empty state instead of a blanket "install it first".
13
+ *
14
+ * The store supplies only what differs: how to fetch its sources + connections, and the
15
+ * connect/disconnect calls (which feed `upsertConnection` / `removeConnection`). Source-
16
+ * specific extras (diagnostics, per-source enable toggles, plan/spawn) stay in the store.
17
+ */
18
+ export function useSourceIntegration<
19
+ Source extends string,
20
+ Conn extends { source: Source },
21
+ Desc extends { source: Source },
22
+ >(opts: {
23
+ /** Fetch the configured sources + live connections; throws when the integration is off. */
24
+ fetch: () => Promise<{ sources: Desc[]; connections: Conn[] }>
25
+ /** Gate the probe (e.g. skip until a workspace is selected). */
26
+ enabled?: () => boolean
27
+ }): {
28
+ available: Ref<boolean | null>
29
+ probeError: Ref<{ status: number | null; message: string } | null>
30
+ sources: Ref<Desc[]>
31
+ connections: Ref<Conn[]>
32
+ connectedSources: ComputedRef<Desc[]>
33
+ anyConnected: ComputedRef<boolean>
34
+ descriptorFor: (source: Source) => Desc | undefined
35
+ connectionFor: (source: Source) => Conn | undefined
36
+ isConnected: (source: Source) => boolean
37
+ upsertConnection: (conn: Conn) => void
38
+ removeConnection: (source: Source) => void
39
+ probe: () => Promise<void>
40
+ } {
41
+ /** null = unknown (not probed yet), true/false = integration on/off. */
42
+ const available = ref<boolean | null>(null)
43
+ /** Why the last probe failed, when it did (kept rather than swallowed). */
44
+ const probeError = ref<{ status: number | null; message: string } | null>(null)
45
+ const sources = ref<Desc[]>([]) as Ref<Desc[]>
46
+ const { items: connections, upsert: upsertConnection } = useUpsertList<Conn>({
47
+ key: (c) => c.source,
48
+ })
49
+
50
+ const connectedSources = computed(() =>
51
+ sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
52
+ )
53
+ const anyConnected = computed(() => connections.value.length > 0)
54
+
55
+ function descriptorFor(source: Source): Desc | undefined {
56
+ return sources.value.find((s) => s.source === source)
57
+ }
58
+ function connectionFor(source: Source): Conn | undefined {
59
+ return connections.value.find((c) => c.source === source)
60
+ }
61
+ function isConnected(source: Source): boolean {
62
+ return connectionFor(source) !== undefined
63
+ }
64
+ function removeConnection(source: Source) {
65
+ connections.value = connections.value.filter((c) => c.source !== source)
66
+ }
67
+
68
+ /** Probe the integration: resolves `available`, the sources and connections. */
69
+ async function probe() {
70
+ if (opts.enabled && !opts.enabled()) return
71
+ try {
72
+ const { sources: srcs, connections: conns } = await opts.fetch()
73
+ available.value = true
74
+ probeError.value = null
75
+ sources.value = srcs
76
+ connections.value = conns
77
+ } catch (e) {
78
+ // 503 (integration disabled) or any error → hide the UI entry points, but keep the
79
+ // reason so a panel can explain it (503 = off here; 500 = the backend errored, e.g. an
80
+ // unapplied migration).
81
+ available.value = false
82
+ const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
83
+ const serverMessage = err?.data?.error?.message
84
+ probeError.value = {
85
+ status: err?.statusCode ?? null,
86
+ message: serverMessage || (e instanceof Error ? e.message : String(e)),
87
+ }
88
+ sources.value = []
89
+ connections.value = []
90
+ }
91
+ }
92
+
93
+ return {
94
+ available,
95
+ probeError,
96
+ sources,
97
+ connections,
98
+ connectedSources,
99
+ anyConnected,
100
+ descriptorFor,
101
+ connectionFor,
102
+ isConnected,
103
+ upsertConnection,
104
+ removeConnection,
105
+ probe,
106
+ }
107
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useUpsertList } from '~/composables/useUpsertList'
3
+
4
+ interface Item {
5
+ id: string
6
+ v: number
7
+ }
8
+
9
+ describe('useUpsertList', () => {
10
+ it('appends new items by default and replaces in place by key', () => {
11
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id })
12
+ upsert({ id: 'a', v: 1 })
13
+ upsert({ id: 'b', v: 2 })
14
+ upsert({ id: 'a', v: 9 }) // replace, not duplicate
15
+ expect(items.value).toEqual([
16
+ { id: 'a', v: 9 },
17
+ { id: 'b', v: 2 },
18
+ ])
19
+ })
20
+
21
+ it('prepends new items when prepend is set (newest-first)', () => {
22
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
23
+ upsert({ id: 'a', v: 1 })
24
+ upsert({ id: 'b', v: 2 })
25
+ expect(items.value.map((x) => x.id)).toEqual(['b', 'a'])
26
+ })
27
+
28
+ it('removes by key and looks up by key', () => {
29
+ const { items, upsert, remove, get } = useUpsertList<Item>({ key: (x) => x.id })
30
+ upsert({ id: 'a', v: 1 })
31
+ upsert({ id: 'b', v: 2 })
32
+ expect(get('b')).toEqual({ id: 'b', v: 2 })
33
+ remove('a')
34
+ expect(items.value.map((x) => x.id)).toEqual(['b'])
35
+ remove('missing') // no-op
36
+ expect(items.value).toHaveLength(1)
37
+ })
38
+
39
+ it('supports composite keys and hydrate-from-snapshot', () => {
40
+ interface Doc {
41
+ source: string
42
+ externalId: string
43
+ }
44
+ const { items, upsert, hydrate } = useUpsertList<Doc>({
45
+ key: (d) => `${d.source}:${d.externalId}`,
46
+ })
47
+ hydrate([{ source: 'jira', externalId: '1' }])
48
+ upsert({ source: 'jira', externalId: '1' }) // same composite key → replace
49
+ upsert({ source: 'gh', externalId: '1' }) // different source → new
50
+ expect(items.value).toHaveLength(2)
51
+ })
52
+
53
+ it('seeds from initial without aliasing the caller array', () => {
54
+ const seed: Item[] = [{ id: 'a', v: 1 }]
55
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, initial: seed })
56
+ upsert({ id: 'b', v: 2 })
57
+ expect(items.value).toHaveLength(2)
58
+ expect(seed).toHaveLength(1) // original untouched
59
+ })
60
+ })
@@ -0,0 +1,57 @@
1
+ import { type Ref, ref } from 'vue'
2
+
3
+ /**
4
+ * A keyed list with find-by-key upsert — the pattern reimplemented in ~13 stores
5
+ * (`const i = list.findIndex((x) => x.id === item.id); if (i >= 0) list[i] = item else …`).
6
+ * Wraps a reactive `T[]` and exposes `upsert` (replace-in-place or insert), `remove`,
7
+ * `get`, and `hydrate` (replace from a server snapshot), all keyed by a caller-supplied
8
+ * `key` function. New items append by default, or `prepend: true` for newest-first inboxes.
9
+ *
10
+ * The returned `items` ref stays directly assignable, so a store can expose it under a
11
+ * domain name (`const { items: documents, upsert } = useUpsertList(...)`) and callers /
12
+ * tests can still do `store.documents = [...]`.
13
+ */
14
+ export function useUpsertList<T>(opts: {
15
+ /** Stable identity for an item (e.g. `(x) => x.id`, or `(x) => `${x.source}:${x.externalId}``). */
16
+ key: (item: T) => unknown
17
+ /** Insert position for a brand-new item: `true` ⇒ unshift (newest-first), else push. */
18
+ prepend?: boolean
19
+ /** Seed contents (copied, not aliased). */
20
+ initial?: T[]
21
+ }): {
22
+ items: Ref<T[]>
23
+ upsert: (item: T) => void
24
+ remove: (keyValue: unknown) => void
25
+ get: (keyValue: unknown) => T | undefined
26
+ hydrate: (next: T[]) => void
27
+ indexOf: (keyValue: unknown) => number
28
+ } {
29
+ const items = ref<T[]>(opts.initial ? [...opts.initial] : []) as Ref<T[]>
30
+
31
+ function indexOf(keyValue: unknown): number {
32
+ return items.value.findIndex((x) => opts.key(x) === keyValue)
33
+ }
34
+
35
+ function upsert(item: T) {
36
+ const i = indexOf(opts.key(item))
37
+ if (i >= 0) items.value[i] = item
38
+ else if (opts.prepend) items.value.unshift(item)
39
+ else items.value.push(item)
40
+ }
41
+
42
+ function remove(keyValue: unknown) {
43
+ const i = indexOf(keyValue)
44
+ if (i >= 0) items.value.splice(i, 1)
45
+ }
46
+
47
+ function get(keyValue: unknown): T | undefined {
48
+ const i = indexOf(keyValue)
49
+ return i >= 0 ? items.value[i] : undefined
50
+ }
51
+
52
+ function hydrate(next: T[]) {
53
+ items.value = [...next]
54
+ }
55
+
56
+ return { items, upsert, remove, get, hydrate, indexOf }
57
+ }
@@ -28,6 +28,7 @@ import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
28
28
  import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
29
29
  import CommandBar from '~/components/layout/CommandBar.vue'
30
30
  import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
31
+ import PersonalSetupModal from '~/components/layout/PersonalSetupModal.vue'
31
32
  import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
32
33
  import AccountSettingsPanel from '~/components/settings/AccountSettingsPanel.vue'
33
34
  import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
@@ -189,6 +190,7 @@ watch(
189
190
  <FragmentLibraryPanel />
190
191
  <CommandBar />
191
192
  <IntegrationsHub />
193
+ <PersonalSetupModal />
192
194
  <WorkspaceSettingsPanel />
193
195
  <AccountSettingsPanel />
194
196
  <ObservabilityConnectionPanel />
@@ -1,3 +1,4 @@
1
+ import type { LocalModeConfig } from '@cat-factory/contracts'
1
2
  import { defineStore } from 'pinia'
2
3
  import { computed, ref } from 'vue'
3
4
  import type { AuthUser } from '~/types/domain'
@@ -29,7 +30,7 @@ export const useAuthStore = defineStore(
29
30
  * `githubPatSetupUrl` is set when local mode has no GitHub PAT configured (drives the
30
31
  * setup banner). Null on every other facade.
31
32
  */
32
- const localMode = ref<{ enabled: boolean; githubPatSetupUrl?: string } | null>(null)
33
+ const localMode = ref<LocalModeConfig | null>(null)
33
34
  /** True once the initial auth handshake has settled. */
34
35
  const ready = ref(false)
35
36
 
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
+ import type { UpdateBlockInput } from '@cat-factory/contracts'
3
4
  import type { Block, BlockType, CreateTaskType, TaskTypeFields } from '~/types/domain'
4
5
  import { useServicesStore } from '~/stores/services'
5
6
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -295,7 +296,7 @@ export const useBoardStore = defineStore('board', () => {
295
296
  }
296
297
 
297
298
  /** Patch the user-editable fields of a block (title, features, threshold…). */
298
- async function updateBlock(id: string, patch: Partial<Block>) {
299
+ async function updateBlock(id: string, patch: UpdateBlockInput) {
299
300
  const b = getBlock(id)
300
301
  if (!b) return
301
302
  Object.assign(b, patch) // optimistic
@@ -1,10 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type {
4
+ BrainstormItemStatus,
4
5
  BrainstormSession,
5
6
  BrainstormStage,
6
7
  ResolveBrainstormExceededChoice,
7
- ReviewItemStatus,
8
8
  } from '~/types/brainstorm'
9
9
  import { useWorkspaceStore } from '~/stores/workspace'
10
10
 
@@ -123,7 +123,7 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
123
123
  async function setItemStatus(
124
124
  session: BrainstormSession,
125
125
  itemId: string,
126
- status: ReviewItemStatus,
126
+ status: BrainstormItemStatus,
127
127
  ) {
128
128
  store(await api.setBrainstormItemStatus(workspace.requireId(), session.id, itemId, status))
129
129
  }
@@ -1,6 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type { ClarityReview, ResolveClarityExceededChoice, ReviewItemStatus } from '~/types/clarity'
3
+ import type {
4
+ ClarityItemStatus,
5
+ ClarityReview,
6
+ ResolveClarityExceededChoice,
7
+ } from '~/types/clarity'
4
8
  import { useWorkspaceStore } from '~/stores/workspace'
5
9
 
6
10
  /**
@@ -120,7 +124,7 @@ export const useClarityStore = defineStore('clarity', () => {
120
124
  }
121
125
 
122
126
  /** Set an item's status (dismiss / reopen). */
123
- async function setItemStatus(review: ClarityReview, itemId: string, status: ReviewItemStatus) {
127
+ async function setItemStatus(review: ClarityReview, itemId: string, status: ClarityItemStatus) {
124
128
  store(await api.setClarityItemStatus(workspace.requireId(), review.id, itemId, status))
125
129
  }
126
130