@cat-factory/app 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/app/components/auth/AuthGate.vue +8 -0
  2. package/app/components/auth/LoginScreen.vue +86 -8
  3. package/app/components/auth/ResetPasswordScreen.vue +106 -0
  4. package/app/components/board/nodes/BlockNode.vue +32 -13
  5. package/app/components/bootstrap/BootstrapModal.vue +10 -6
  6. package/app/components/documents/DocumentImportModal.vue +11 -7
  7. package/app/components/github/AddServiceFromRepoModal.vue +9 -5
  8. package/app/components/github/GitHubPanel.vue +8 -4
  9. package/app/components/kaizen/KaizenPanel.vue +7 -3
  10. package/app/components/layout/IntegrationsHub.vue +2 -0
  11. package/app/components/panels/ObservabilityPanel.vue +12 -7
  12. package/app/components/providers/VendorCredentialsModal.vue +10 -6
  13. package/app/components/sandbox/SandboxPanel.vue +30 -19
  14. package/app/components/settings/IssueTrackerPanel.vue +3 -1
  15. package/app/components/settings/LocalModeSettingsPanel.vue +7 -3
  16. package/app/components/settings/LocalModelEndpointsPanel.vue +7 -3
  17. package/app/components/settings/ModelConfigurationPanel.vue +12 -8
  18. package/app/components/settings/ObservabilityConnectionPanel.vue +16 -12
  19. package/app/components/settings/OpenRouterCatalogPanel.vue +14 -9
  20. package/app/components/settings/ProviderConnectionPanel.vue +4 -4
  21. package/app/components/settings/UserSecretsSection.vue +7 -3
  22. package/app/components/settings/WorkspaceSettingsPanel.vue +3 -1
  23. package/app/components/slack/SlackPanel.vue +2 -0
  24. package/app/composables/api/auth.ts +11 -0
  25. package/app/composables/useBlockQueries.ts +31 -9
  26. package/app/pages/index.vue +103 -51
  27. package/app/pages/reset-password.vue +7 -0
  28. package/app/stores/auth.ts +12 -0
  29. package/app/stores/board.spec.ts +30 -0
  30. package/app/stores/board.ts +27 -2
  31. package/app/stores/brainstorm.ts +11 -0
  32. package/app/stores/clarity.ts +11 -0
  33. package/app/stores/consensus.ts +7 -1
  34. package/app/stores/execution.spec.ts +43 -0
  35. package/app/stores/execution.ts +19 -0
  36. package/app/stores/github.ts +17 -0
  37. package/app/stores/requirements.ts +12 -0
  38. package/app/stores/workspace.ts +17 -0
  39. package/package.json +2 -2
@@ -55,14 +55,18 @@ const filteredKinds = computed(() => {
55
55
  )
56
56
  })
57
57
 
58
- watch(open, (isOpen) => {
59
- if (isOpen) {
60
- editor.value = null
61
- filter.value = ''
62
- void models.ensureLoaded(workspace.workspaceId ?? undefined)
63
- if (workspace.workspaceId) void creds.load(workspace.workspaceId)
64
- }
65
- })
58
+ watch(
59
+ open,
60
+ (isOpen) => {
61
+ if (isOpen) {
62
+ editor.value = null
63
+ filter.value = ''
64
+ void models.ensureLoaded(workspace.workspaceId ?? undefined)
65
+ if (workspace.workspaceId) void creds.load(workspace.workspaceId)
66
+ }
67
+ },
68
+ { immediate: true },
69
+ )
66
70
 
67
71
  onKeyStroke('Escape', () => {
68
72
  if (!open.value) return
@@ -42,18 +42,22 @@ function notifyError(title: string, e: unknown) {
42
42
  })
43
43
  }
44
44
 
45
- watch(open, async (isOpen) => {
46
- if (!isOpen) return
47
- try {
48
- await store.ensureLoaded()
49
- if (store.connection.provider) provider.value = store.connection.provider
50
- const site = store.connection.summary?.site
51
- if (site) datadog.site = site
52
- await store.loadIncident()
53
- } catch (e) {
54
- notifyError('Could not load observability settings', e)
55
- }
56
- })
45
+ watch(
46
+ open,
47
+ async (isOpen) => {
48
+ if (!isOpen) return
49
+ try {
50
+ await store.ensureLoaded()
51
+ if (store.connection.provider) provider.value = store.connection.provider
52
+ const site = store.connection.summary?.site
53
+ if (site) datadog.site = site
54
+ await store.loadIncident()
55
+ } catch (e) {
56
+ notifyError('Could not load observability settings', e)
57
+ }
58
+ },
59
+ { immediate: true },
60
+ )
57
61
 
58
62
  async function saveIncident() {
59
63
  incidentBusy.value = true
@@ -51,15 +51,20 @@ const connectingKey = ref(false)
51
51
 
52
52
  // Load key state + persisted catalog whenever the panel opens; seed the tick selection,
53
53
  // then auto-refresh the live catalog if a key is already connected (no extra click).
54
- watch(open, (isOpen) => {
55
- if (!isOpen || !workspace.workspaceId) return
56
- const ws = workspace.workspaceId
57
- void apiKeys.load(ws).catch(() => {})
58
- void store.load(ws).then(() => {
59
- selected.value = new Set(store.enabled.map((m) => m.id))
60
- if (keyConnected.value && store.browse.length === 0) void refresh()
61
- })
62
- })
54
+ watch(
55
+ open,
56
+ (isOpen) => {
57
+ // Lazy v-if mount runs this immediately (see below); guard still skips the closed case.
58
+ if (!isOpen || !workspace.workspaceId) return
59
+ const ws = workspace.workspaceId
60
+ void apiKeys.load(ws).catch(() => {})
61
+ void store.load(ws).then(() => {
62
+ selected.value = new Set(store.enabled.map((m) => m.id))
63
+ if (keyConnected.value && store.browse.length === 0) void refresh()
64
+ })
65
+ },
66
+ { immediate: true },
67
+ )
63
68
 
64
69
  // The list to show: the live browse list once refreshed, else the persisted enabled set.
65
70
  const source = computed<OpenRouterModelMeta[]>(() =>
@@ -7,7 +7,7 @@
7
7
  // see backend/docs/native-environment-adapter.md): a `secret` field → the write-only secret
8
8
  // bundle, a non-secret field → providerConfig[key], a `baseUrl` field → baseUrl. A field
9
9
  // with a `default` is optional — left blank it falls back to that default.
10
- import { computed, ref, watch } from 'vue'
10
+ import { computed, ref, toRaw, watch } from 'vue'
11
11
  import type { ProviderConnectionKind } from '~/types/providerConnections'
12
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
13
13
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
@@ -163,9 +163,9 @@ function buildManifestPayload(): {
163
163
  const template = descriptor.value?.manifestTemplate
164
164
  if (!template) return null
165
165
  const base = descriptor.value?.savedManifest ?? template
166
- // `base` is a Vue reactive proxy, which structuredClone refuses (DataCloneError). The
167
- // manifest is plain JSON config, so a JSON round-trip both unwraps the proxy and deep-clones.
168
- const manifest: Record<string, unknown> = JSON.parse(JSON.stringify(base))
166
+ // `base` is a Vue reactive proxy, which structuredClone refuses (DataCloneError). `toRaw`
167
+ // unwraps it to the underlying plain-JSON config so structuredClone can deep-clone it.
168
+ const manifest: Record<string, unknown> = structuredClone(toRaw(base))
169
169
  const providerConfig: Record<string, unknown> = {
170
170
  ...(manifest.providerConfig as Record<string, unknown> | undefined),
171
171
  }
@@ -40,9 +40,13 @@ function resetDraft() {
40
40
  if (meta) for (const [k, v] of Object.entries(meta)) values.value[k] = v
41
41
  }
42
42
 
43
- watch(open, (isOpen) => {
44
- if (isOpen) void store.load().then(resetDraft)
45
- })
43
+ watch(
44
+ open,
45
+ (isOpen) => {
46
+ if (isOpen) void store.load().then(resetDraft)
47
+ },
48
+ { immediate: true },
49
+ )
46
50
  watch(kind, resetDraft)
47
51
 
48
52
  const secretField = computed<ProviderConfigField | undefined>(() =>
@@ -87,7 +87,9 @@ function hydrate() {
87
87
  draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
88
88
  }
89
89
 
90
- watch(() => store.settings, hydrate, { immediate: true, deep: true })
90
+ // `store.settings` is always replaced wholesale (store hydrate/update reassign the ref),
91
+ // so tracking the object reference is enough — no deep per-field traversal needed.
92
+ watch(() => store.settings, hydrate, { immediate: true })
91
93
 
92
94
  const saving = ref(false)
93
95
 
@@ -78,6 +78,8 @@ watch(
78
78
  notifyError('Could not load Slack settings', e)
79
79
  }
80
80
  },
81
+ // Lazy v-if mount: the panel mounts with `open` already true, so load immediately.
82
+ { immediate: true },
81
83
  )
82
84
 
83
85
  async function connectViaOAuth() {
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  acceptInvitationContract,
3
3
  authConfigContract,
4
+ forgotPasswordContract,
4
5
  logoutContract,
5
6
  meContract,
6
7
  passwordLoginContract,
7
8
  peekInvitationContract,
9
+ resetPasswordContract,
8
10
  signupContract,
9
11
  } from '@cat-factory/contracts'
10
12
  import type { ApiContext } from './context'
@@ -25,6 +27,15 @@ export function authApi({ http, send, ws }: ApiContext) {
25
27
  passwordLogin: (body: { email: string; password: string }) =>
26
28
  send(passwordLoginContract, { pathPrefix: '/auth', body }),
27
29
 
30
+ // Request a reset link. Always succeeds (204) regardless of whether the email is
31
+ // registered, so the response can't be used to enumerate accounts.
32
+ forgotPassword: (body: { email: string }) =>
33
+ send(forgotPasswordContract, { pathPrefix: '/auth', body }),
34
+
35
+ // Redeem a reset token + set a new password (throws 400 on an invalid/expired token).
36
+ resetPassword: (body: { token: string; password: string }) =>
37
+ send(resetPasswordContract, { pathPrefix: '/auth', body }),
38
+
28
39
  peekInvite: (token: string) =>
29
40
  send(peekInvitationContract, { pathPrefix: '/auth', pathParams: { token } }),
30
41
 
@@ -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
- 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
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 byId.value.get(id)
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 blocks.value.filter((b) => b.parentId === parentId)
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 blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
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 blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
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 blocks.value.filter((b) => b.epicId === epicId)
86
+ return index.value.membersByEpic.get(epicId) ?? []
65
87
  }
66
88
 
67
89
  /** The epic a task belongs to, if any. */
@@ -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
- import AiProviderOnboardingModal from '~/components/providers/AiProviderOnboardingModal.vue'
46
- import AiPresetMismatchDialog from '~/components/providers/AiPresetMismatchDialog.vue'
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
- <AiProviderOnboardingModal />
207
- <AiPresetMismatchDialog />
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 -->
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import ResetPasswordScreen from '~/components/auth/ResetPasswordScreen.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <ResetPasswordScreen />
7
+ </template>
@@ -133,6 +133,16 @@ export const useAuthStore = defineStore(
133
133
  applySession(await api.passwordLogin(body))
134
134
  }
135
135
 
136
+ /** Request a password-reset link by email (always resolves; never reveals existence). */
137
+ async function forgotPassword(email: string) {
138
+ await api.forgotPassword({ email })
139
+ }
140
+
141
+ /** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
142
+ async function resetPassword(token: string, password: string) {
143
+ await api.resetPassword({ token, password })
144
+ }
145
+
136
146
  /** Drop the local session (sessions are stateless server-side). */
137
147
  function logout() {
138
148
  api.logout().catch(() => {})
@@ -159,6 +169,8 @@ export const useAuthStore = defineStore(
159
169
  loginWithGoogle,
160
170
  signup,
161
171
  passwordLogin,
172
+ forgotPassword,
173
+ resetPassword,
162
174
  logout,
163
175
  handleUnauthorized,
164
176
  }
@@ -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')
@@ -29,9 +29,34 @@ export const useBoardStore = defineStore('board', () => {
29
29
  const queries = useBlockQueries(blocks)
30
30
  const { getBlock } = queries
31
31
 
32
- /** Replace the cached blocks with a server snapshot. */
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 = next
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. */
@@ -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
  }
@@ -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
  }
@@ -56,5 +56,11 @@ export const useConsensusStore = defineStore('consensus', () => {
56
56
  }
57
57
  }
58
58
 
59
- return { sessions, sessionFor, isLoading, load, upsert }
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
  })