@cat-factory/app 0.116.6 → 0.116.7

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.
@@ -14,7 +14,9 @@ const { t } = useI18n()
14
14
  // out, so it must render even when auth is required and there's no user.
15
15
  const isPublicRoute = computed(() => route.path === '/reset-password')
16
16
 
17
- onMounted(() => auth.bootstrap())
17
+ // Stamp the first cold-open milestone once the auth handshake settles (app-startup initiative,
18
+ // item 1) — bootstrap resolves even on failure (it catches internally), so `finally` always fires.
19
+ onMounted(() => void auth.bootstrap().finally(() => markBoot('auth-ready')))
18
20
  </script>
19
21
 
20
22
  <template>
@@ -100,11 +100,14 @@ watch(
100
100
  () => workspace.workspaceId,
101
101
  (id) => {
102
102
  if (!id) return
103
- void documents.probe()
104
- void tasks.probe()
105
- void github.probe()
106
- void slack.probe()
107
- void library.probe()
103
+ // `ensureProbed` single-flights per board (app-startup initiative, item 12): on a cold open
104
+ // these coalesce with the board page's own github probe and don't refire on a re-mount, while a
105
+ // workspace switch (new id) still re-probes. `probe()` stays the explicit post-connect refresh.
106
+ void documents.ensureProbed()
107
+ void tasks.ensureProbed()
108
+ void github.ensureProbed()
109
+ void slack.ensureProbed()
110
+ void library.ensureProbed()
108
111
  void providerConnections.ensureLoaded().catch(() => {})
109
112
  },
110
113
  { immediate: true },
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
3
+
4
+ // Single-flight probe guard (app-startup initiative, item 12). Pure logic, no Pinia/Nuxt: a fake
5
+ // `run` (counting calls, with a manually-resolved promise) and a mutable `id` getter.
6
+
7
+ /** A `run` whose promise the test resolves by hand, plus a call counter. */
8
+ function deferredRun() {
9
+ let resolve!: () => void
10
+ const calls = { count: 0 }
11
+ const run = vi.fn(() => {
12
+ calls.count++
13
+ return new Promise<void>((r) => (resolve = r))
14
+ })
15
+ return { run, calls, resolve: () => resolve() }
16
+ }
17
+
18
+ describe('useSingleFlightProbe', () => {
19
+ it('coalesces concurrent probe() calls for the same board into one run', async () => {
20
+ const { run, calls, resolve } = deferredRun()
21
+ const { probe } = useSingleFlightProbe(run, () => 'ws1')
22
+
23
+ const a = probe()
24
+ const b = probe()
25
+ expect(calls.count).toBe(1) // one in-flight run shared by both callers
26
+ resolve()
27
+ await Promise.all([a, b])
28
+ })
29
+
30
+ it('ensureProbed() is a no-op once the board is already probed', async () => {
31
+ const { run, calls, resolve } = deferredRun()
32
+ const { ensureProbed } = useSingleFlightProbe(run, () => 'ws1')
33
+
34
+ const first = ensureProbed()
35
+ resolve()
36
+ await first
37
+ expect(calls.count).toBe(1)
38
+
39
+ await ensureProbed() // already settled for ws1 → does not run again
40
+ expect(calls.count).toBe(1)
41
+ })
42
+
43
+ it('ensureProbed() re-runs when the workspace id changes', async () => {
44
+ const { run, calls, resolve } = deferredRun()
45
+ let id = 'ws1'
46
+ const { ensureProbed } = useSingleFlightProbe(run, () => id)
47
+
48
+ const first = ensureProbed()
49
+ resolve()
50
+ await first
51
+ expect(calls.count).toBe(1)
52
+
53
+ id = 'ws2' // a switch — connections are per board, so it must re-probe
54
+ const second = ensureProbed()
55
+ resolve()
56
+ await second
57
+ expect(calls.count).toBe(2)
58
+ })
59
+
60
+ it('probe() always re-runs (a deliberate refresh) even after a completed probe', async () => {
61
+ const { run, calls, resolve } = deferredRun()
62
+ const guard = useSingleFlightProbe(run, () => 'ws1')
63
+
64
+ const first = guard.probe()
65
+ resolve()
66
+ await first
67
+ expect(calls.count).toBe(1)
68
+
69
+ const refresh = guard.probe() // e.g. after a connect — re-reads
70
+ resolve()
71
+ await refresh
72
+ expect(calls.count).toBe(2)
73
+ })
74
+
75
+ it('a probe() refresh in flight is shared by a concurrent ensureProbed()', async () => {
76
+ const { run, calls, resolve } = deferredRun()
77
+ const guard = useSingleFlightProbe(run, () => 'ws1')
78
+
79
+ const refresh = guard.probe()
80
+ const ensured = guard.ensureProbed() // rides the in-flight probe rather than firing a duplicate
81
+ expect(calls.count).toBe(1)
82
+ resolve()
83
+ await Promise.all([refresh, ensured])
84
+ })
85
+
86
+ it('a superseded out-of-order completion does not stamp a stale probedId', async () => {
87
+ // ws1 probe starts, then a switch to ws2 starts a second — and ws2 (the newer, current board)
88
+ // resolves BEFORE the older ws1 probe. The late ws1 completion must NOT record ws1 as the
89
+ // settled board, else the next ensureProbed() for ws2 would redundantly re-run.
90
+ let id = 'ws1'
91
+ let n = 0
92
+ let resolve1!: () => void
93
+ let resolve2!: () => void
94
+ const run = vi.fn(() => {
95
+ n++
96
+ return new Promise<void>((r) => (n === 1 ? (resolve1 = r) : (resolve2 = r)))
97
+ })
98
+ const { ensureProbed } = useSingleFlightProbe(run, () => id)
99
+
100
+ const p1 = ensureProbed() // starts ws1
101
+ id = 'ws2'
102
+ const p2 = ensureProbed() // starts ws2 (a switch — different id)
103
+ expect(run).toHaveBeenCalledTimes(2)
104
+
105
+ resolve2() // the current board settles first
106
+ await p2
107
+ resolve1() // the superseded older probe settles late
108
+ await p1
109
+
110
+ // ws2 is the current, settled board → ensureProbed() is a no-op, not a third (redundant) run.
111
+ await ensureProbed()
112
+ expect(run).toHaveBeenCalledTimes(2)
113
+ })
114
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Single-flight guard for a per-workspace integration probe (app-startup initiative, item 12).
3
+ *
4
+ * On a board open the same probe is fired from several places at once — e.g. `github.probe()` runs
5
+ * from both the board page (to resolve the onboarding gate) and the SideBar, and the SideBar fans
6
+ * out five more (`documents` / `tasks` / `slack` / `library` / provider connections). Each was an
7
+ * independent network call, and a re-mount re-ran them. This wraps a store's probe with two
8
+ * behaviours keyed on the active workspace id:
9
+ *
10
+ * - {@link probe} — always re-runs the probe (deliberate refresh, e.g. after a connect), but a
11
+ * burst of concurrent callers on ONE board open shares the single in-flight request.
12
+ * - {@link ensureProbed} — runs the probe AT MOST ONCE per workspace: a no-op when this board is
13
+ * already probed, or the shared in-flight promise otherwise. This is what the on-board-open
14
+ * fan-out uses, so the duplicate/refire collapses to one call — while a workspace SWITCH (a new
15
+ * id) still re-probes, since connections are per board.
16
+ *
17
+ * The id-keying means no explicit reset on workspace change: a call for a different id than the last
18
+ * completed probe re-runs. `run` reads whatever workspace-scoped state it needs itself; `currentId`
19
+ * only supplies the key (and the "which board did this settle for" record).
20
+ */
21
+ interface SingleFlightProbe {
22
+ probe: () => Promise<void>
23
+ ensureProbed: () => Promise<void>
24
+ }
25
+
26
+ export function useSingleFlightProbe(
27
+ run: () => Promise<void>,
28
+ currentId: () => string | null,
29
+ ): SingleFlightProbe {
30
+ let inFlight: Promise<void> | null = null
31
+ let inFlightId: string | null = null
32
+ let probedId: string | null = null
33
+
34
+ function start(id: string | null): Promise<void> {
35
+ const p = Promise.resolve(run()).finally(() => {
36
+ // Only record "settled for this board" / clear the in-flight slot when a NEWER probe hasn't
37
+ // superseded us. Otherwise an out-of-order completion — an older probe for board A resolving
38
+ // after a newer probe for board B — would stamp probedId back to A and force a redundant
39
+ // re-probe of B on the next ensureProbed. The newer probe owns `inFlight`, so it records the
40
+ // board that's actually current.
41
+ if (inFlight === p) {
42
+ probedId = id
43
+ inFlight = null
44
+ inFlightId = null
45
+ }
46
+ })
47
+ inFlight = p
48
+ inFlightId = id
49
+ return p
50
+ }
51
+
52
+ function probe(): Promise<void> {
53
+ const id = currentId()
54
+ // Share an already-running probe for the same board (the concurrent-burst case); otherwise
55
+ // start a fresh one — a deliberate refresh must always re-read.
56
+ if (inFlight && inFlightId === id) return inFlight
57
+ return start(id)
58
+ }
59
+
60
+ function ensureProbed(): Promise<void> {
61
+ const id = currentId()
62
+ // Already settled for this board and nothing running → nothing to do.
63
+ if (probedId === id && !inFlight) return Promise.resolve()
64
+ // A probe for this board is in flight → ride it rather than firing a duplicate.
65
+ if (inFlight && inFlightId === id) return inFlight
66
+ return start(id)
67
+ }
68
+
69
+ return { probe, ensureProbed }
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ComputedRef, type Ref, computed, ref } from 'vue'
2
2
  import { apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
3
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
3
4
  import { useUpsertList } from '~/composables/useUpsertList'
4
5
 
5
6
  /**
@@ -25,6 +26,12 @@ export function useSourceIntegration<
25
26
  fetch: () => Promise<{ sources: Desc[]; connections: Conn[] }>
26
27
  /** Gate the probe (e.g. skip until a workspace is selected). */
27
28
  enabled?: () => boolean
29
+ /**
30
+ * The active workspace id, keying the single-flight probe (app-startup initiative, item 12): a
31
+ * board-open fan-out via `ensureProbed` hits the source once per board, and a workspace switch
32
+ * (new id) re-probes. Omitted ⇒ a single bucket (probe-once for the composable's lifetime).
33
+ */
34
+ workspaceId?: () => string | null
28
35
  }): {
29
36
  available: Ref<boolean | null>
30
37
  probeError: Ref<{ status: number | null; message: string } | null>
@@ -38,6 +45,7 @@ export function useSourceIntegration<
38
45
  upsertConnection: (conn: Conn) => void
39
46
  removeConnection: (source: Source) => void
40
47
  probe: () => Promise<void>
48
+ ensureProbed: () => Promise<void>
41
49
  } {
42
50
  /** null = unknown (not probed yet), true/false = integration on/off. */
43
51
  const available = ref<boolean | null>(null)
@@ -67,7 +75,7 @@ export function useSourceIntegration<
67
75
  }
68
76
 
69
77
  /** Probe the integration: resolves `available`, the sources and connections. */
70
- async function probe() {
78
+ async function runProbe() {
71
79
  if (opts.enabled && !opts.enabled()) return
72
80
  try {
73
81
  const { sources: srcs, connections: conns } = await opts.fetch()
@@ -89,6 +97,13 @@ export function useSourceIntegration<
89
97
  connections.value = []
90
98
  }
91
99
  }
100
+ // Single-flight the probe (app-startup initiative, item 12): `probe()` still re-reads on demand
101
+ // (a connect/disconnect refresh), but the board-open fan-out uses `ensureProbed` so it fetches
102
+ // once per board rather than on every SideBar/page (re)mount.
103
+ const { probe, ensureProbed } = useSingleFlightProbe(
104
+ runProbe,
105
+ opts.workspaceId ?? (() => 'source-integration'),
106
+ )
92
107
 
93
108
  return {
94
109
  available,
@@ -103,5 +118,6 @@ export function useSourceIntegration<
103
118
  upsertConnection,
104
119
  removeConnection,
105
120
  probe,
121
+ ensureProbed,
106
122
  }
107
123
  }
@@ -245,12 +245,13 @@ watch(
245
245
 
246
246
  // Probe the GitHub integration as soon as a board is active (re-probe per board —
247
247
  // connections are per workspace). The result drives the onboarding gate below
248
- // before the board mounts, so an unconnected user can't slip past it. SideBar
249
- // re-probes once it mounts; that duplicate is harmless (probe is idempotent).
248
+ // before the board mounts, so an unconnected user can't slip past it. `ensureProbed`
249
+ // single-flights per board (app-startup initiative, item 12), so this and the SideBar's
250
+ // probe collapse to one request on a cold open instead of two.
250
251
  watch(
251
252
  () => workspace.workspaceId,
252
253
  (id) => {
253
- if (id) void github.probe()
254
+ if (id) void github.ensureProbed()
254
255
  },
255
256
  { immediate: true },
256
257
  )
@@ -268,6 +269,9 @@ const stream = useWorkspaceStream()
268
269
  // in the template would not unwrap, since `stream` is a plain object). Drives the headless
269
270
  // `workspace-stream` readiness marker the e2e suite waits on.
270
271
  const streamConnected = computed(() => stream.connected.value)
272
+ // Final cold-open milestone (app-startup initiative, item 1): a live, reconciled board. `markBoot`
273
+ // fires once, so only the first connect of the session times the waterfall's tail.
274
+ watch(streamConnected, (c) => c && markBoot('stream-connected'), { immediate: true })
271
275
  const streamEverConnected = computed(() => stream.everConnected.value)
272
276
  const streamConnectionFailed = computed(() => stream.connectionFailed.value)
273
277
  watch(
@@ -35,6 +35,7 @@ export const useDocumentsStore = defineStore('documents', () => {
35
35
  DocumentSourceDescriptor
36
36
  >({
37
37
  enabled: () => !!workspace.workspaceId,
38
+ workspaceId: () => workspace.workspaceId,
38
39
  fetch: async () => {
39
40
  const [{ sources }, { connections }] = await Promise.all([
40
41
  api.listDocumentSources(workspace.requireId()),
@@ -44,7 +45,7 @@ export const useDocumentsStore = defineStore('documents', () => {
44
45
  },
45
46
  })
46
47
  const { available, sources, connections, connectedSources, anyConnected } = integration
47
- const { descriptorFor, connectionFor, isConnected, probe } = integration
48
+ const { descriptorFor, connectionFor, isConnected, probe, ensureProbed } = integration
48
49
 
49
50
  const { items: documents, upsert: upsertDoc } = useUpsertList<SourceDocument>({
50
51
  key: (d) => `${d.source}:${d.externalId}`,
@@ -187,6 +188,7 @@ export const useDocumentsStore = defineStore('documents', () => {
187
188
  isConnected,
188
189
  docsForBlock,
189
190
  probe,
191
+ ensureProbed,
190
192
  connect,
191
193
  disconnect,
192
194
  loadDocuments,
@@ -10,6 +10,7 @@ import type {
10
10
  ResolvedFragment,
11
11
  UpdatePromptFragmentInput,
12
12
  } from '~/types/domain'
13
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
13
14
  import { useWorkspaceStore } from '~/stores/workspace'
14
15
  import { useFragmentsStore } from '~/stores/fragments'
15
16
 
@@ -62,7 +63,7 @@ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => str
62
63
  }
63
64
 
64
65
  /** Probe the feature + load this owner's tier, sources and (ws) resolved catalog. */
65
- async function probe() {
66
+ async function runProbe() {
66
67
  const id = resolveOwnerId()
67
68
  if (!id) return
68
69
  try {
@@ -82,6 +83,10 @@ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => str
82
83
  resolved.value = []
83
84
  }
84
85
  }
86
+ // Single-flight the probe keyed on the owner id (app-startup initiative, item 12): the SideBar's
87
+ // board-open fan-out uses `ensureProbed`, so it loads once per owner; `probe()` stays the
88
+ // on-demand refresh (a library mutation still re-reads via the explicit calls).
89
+ const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => resolveOwnerId())
85
90
 
86
91
  async function refreshResolved() {
87
92
  // Every library mutation lands here: drop the picker catalog's cache so the
@@ -192,6 +197,7 @@ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => str
192
197
  viaWorkspaceId,
193
198
  builtinCount,
194
199
  probe,
200
+ ensureProbed,
195
201
  refreshResolved,
196
202
  create,
197
203
  createDocumentFragment,
@@ -13,6 +13,7 @@ import type {
13
13
  OpenPullRequestInput,
14
14
  ResyncRequest,
15
15
  } from '~/types/domain'
16
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
16
17
  import { useWorkspaceStore } from '~/stores/workspace'
17
18
  import { useServicesStore } from '~/stores/services'
18
19
 
@@ -96,7 +97,7 @@ export const useGitHubStore = defineStore('github', () => {
96
97
  }
97
98
 
98
99
  /** Probe the integration: resolves `available` and the current connection. */
99
- async function probe() {
100
+ async function runProbe() {
100
101
  if (!workspace.workspaceId) return
101
102
  try {
102
103
  const { connection: conn } = await api.getGitHubConnection(workspace.requireId())
@@ -108,6 +109,11 @@ export const useGitHubStore = defineStore('github', () => {
108
109
  connection.value = null
109
110
  }
110
111
  }
112
+ // Single-flight the probe (app-startup initiative, item 12): `probe()` still re-reads on demand,
113
+ // but the on-board-open callers (the board page's onboarding gate + the SideBar) use
114
+ // `ensureProbed()` so their duplicate fire collapses to one request per board. A workspace switch
115
+ // (new id) re-probes.
116
+ const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => workspace.workspaceId)
111
117
 
112
118
  /** Load the cached repos, pull requests and issues for the workspace. */
113
119
  async function load() {
@@ -327,6 +333,7 @@ export const useGitHubStore = defineStore('github', () => {
327
333
  pullUrl,
328
334
  issueUrl,
329
335
  probe,
336
+ ensureProbed,
330
337
  load,
331
338
  ensureLoaded,
332
339
  loadAvailableRepos,
@@ -6,6 +6,7 @@ import type {
6
6
  SlackMemberMappingEntry,
7
7
  SlackNotificationSettings,
8
8
  } from '~/types/domain'
9
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
9
10
  import { useWorkspaceStore } from '~/stores/workspace'
10
11
 
11
12
  /**
@@ -43,7 +44,8 @@ export const useSlackStore = defineStore('slack', () => {
43
44
  * is off — hide the UI. On success, capture the connection + whether OAuth is
44
45
  * available. Called on workspace change, like the GitHub probe.
45
46
  */
46
- async function probe() {
47
+ async function runProbe() {
48
+ if (!workspace.workspaceId) return
47
49
  try {
48
50
  const { connection: conn, oauthEnabled: oauth } = await api.getSlackConnection(
49
51
  workspace.requireId(),
@@ -56,6 +58,9 @@ export const useSlackStore = defineStore('slack', () => {
56
58
  connection.value = null
57
59
  }
58
60
  }
61
+ // Single-flight the probe (app-startup initiative, item 12) so the SideBar's board-open fan-out
62
+ // (via `ensureProbed`) hits Slack once per board; `probe()` stays the on-demand refresh.
63
+ const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => workspace.workspaceId)
59
64
 
60
65
  /** Resolve the "Add to Slack" OAuth URL (only when oauthEnabled). */
61
66
  function installUrl(): Promise<string> {
@@ -130,6 +135,7 @@ export const useSlackStore = defineStore('slack', () => {
130
135
  saving,
131
136
  connected,
132
137
  probe,
138
+ ensureProbed,
133
139
  installUrl,
134
140
  connectWithToken,
135
141
  disconnect,
@@ -34,6 +34,7 @@ export const useTasksStore = defineStore('tasks', () => {
34
34
  // (integration disabled vs a server/backend error) instead of "install it first".
35
35
  const integration = useSourceIntegration<TaskSourceKind, TaskConnection, TaskSourceState>({
36
36
  enabled: () => !!workspace.workspaceId,
37
+ workspaceId: () => workspace.workspaceId,
37
38
  fetch: async () => {
38
39
  const [{ sources }, { connections }] = await Promise.all([
39
40
  api.listTaskSources(workspace.requireId()),
@@ -44,7 +45,7 @@ export const useTasksStore = defineStore('tasks', () => {
44
45
  })
45
46
  const { available, probeError, sources, connections, connectedSources, anyConnected } =
46
47
  integration
47
- const { descriptorFor, connectionFor, isConnected, probe } = integration
48
+ const { descriptorFor, connectionFor, isConnected, probe, ensureProbed } = integration
48
49
 
49
50
  const { items: tasks, upsert: upsertTask } = useUpsertList<SourceTask>({
50
51
  key: (t) => `${t.source}:${t.externalId}`,
@@ -224,6 +225,7 @@ export const useTasksStore = defineStore('tasks', () => {
224
225
  isConnected,
225
226
  tasksForBlock,
226
227
  probe,
228
+ ensureProbed,
227
229
  checkSetup,
228
230
  connect,
229
231
  startLinearOAuth,
@@ -112,3 +112,70 @@ describe('workspace store refresh ordering', () => {
112
112
  expect(board.getBlock('spawned')).toBeDefined()
113
113
  })
114
114
  })
115
+
116
+ // Cold-open waterfall flattening (app-startup initiative, item 8): `init()` fetches the persisted
117
+ // board's snapshot SPECULATIVELY, in parallel with the workspace list, instead of waiting for the
118
+ // list to resolve before the (heaviest) snapshot fetch. These pin that (a) a still-valid persisted
119
+ // board is opened with EXACTLY ONE snapshot fetch — the reused speculative one — and (b) a stale
120
+ // persisted id falls back cleanly, discarding the speculative result.
121
+ describe('workspace store cold-open speculative snapshot', () => {
122
+ beforeEach(() => {
123
+ // A working accounts store (the inert stub returns non-promises, which init's `.catch` chain +
124
+ // `accountWorkspaces` can't use). Auth off ⇒ all boards are in scope.
125
+ vi.stubGlobal('useAccountsStore', () => ({
126
+ load: async () => {},
127
+ enabled: false,
128
+ activeAccountId: null,
129
+ }))
130
+ })
131
+
132
+ it('reuses the speculative persisted snapshot — one getWorkspace on a cold open', async () => {
133
+ const getWorkspace = vi.fn().mockResolvedValue(snapshot('ws1', [block('f1')]))
134
+ const listWorkspaces = vi.fn().mockResolvedValue([{ id: 'ws1', name: 'ws1', accountId: null }])
135
+ vi.stubGlobal('useApi', () => ({ getWorkspace, listWorkspaces }))
136
+
137
+ const ws = useWorkspaceStore()
138
+ ws.workspaceId = 'ws1' // the persisted board (read from localStorage on a real cold open)
139
+ await ws.init()
140
+
141
+ // The persisted board's snapshot was fetched exactly once (speculatively) and REUSED —
142
+ // resolveActiveBoard did not fetch it a second time.
143
+ expect(listWorkspaces).toHaveBeenCalledTimes(1)
144
+ expect(getWorkspace).toHaveBeenCalledTimes(1)
145
+ expect(getWorkspace).toHaveBeenCalledWith('ws1')
146
+ expect(useBoardStore().getBlock('f1')).toBeDefined()
147
+ })
148
+
149
+ it('falls back to the first board when the persisted id is gone', async () => {
150
+ const getWorkspace = vi.fn(async (id: string) => {
151
+ if (id === 'gone') throw new Error('404') // the speculative fetch for a removed board rejects
152
+ return snapshot(id, [block('f2')])
153
+ })
154
+ const listWorkspaces = vi.fn().mockResolvedValue([{ id: 'ws2', name: 'ws2', accountId: null }])
155
+ vi.stubGlobal('useApi', () => ({ getWorkspace, listWorkspaces }))
156
+
157
+ const ws = useWorkspaceStore()
158
+ ws.workspaceId = 'gone'
159
+ await ws.init()
160
+
161
+ // The rejected speculative fetch didn't wedge init; it fell back to the one board in scope.
162
+ expect(getWorkspace).toHaveBeenNthCalledWith(1, 'gone')
163
+ expect(getWorkspace).toHaveBeenCalledWith('ws2')
164
+ expect(ws.workspaceId).toBe('ws2')
165
+ expect(useBoardStore().getBlock('f2')).toBeDefined()
166
+ })
167
+
168
+ it('no persisted board: no speculative fetch, opens the first board', async () => {
169
+ const getWorkspace = vi.fn().mockResolvedValue(snapshot('ws3', [block('f3')]))
170
+ const listWorkspaces = vi.fn().mockResolvedValue([{ id: 'ws3', name: 'ws3', accountId: null }])
171
+ vi.stubGlobal('useApi', () => ({ getWorkspace, listWorkspaces }))
172
+
173
+ const ws = useWorkspaceStore()
174
+ ws.workspaceId = null
175
+ await ws.init()
176
+
177
+ expect(getWorkspace).toHaveBeenCalledTimes(1)
178
+ expect(getWorkspace).toHaveBeenCalledWith('ws3')
179
+ expect(useBoardStore().getBlock('f3')).toBeDefined()
180
+ })
181
+ })
@@ -32,6 +32,7 @@ import { useConsensusStore } from '~/stores/consensus'
32
32
  import { useGitHubStore } from '~/stores/github'
33
33
  import { useFragmentsStore } from '~/stores/fragments'
34
34
  import { useProviderConnectionsStore } from '~/stores/providerConnections'
35
+ import { markBoot } from '~/utils/bootMarks'
35
36
 
36
37
  /**
37
38
  * Owns the active workspace and bootstraps the app against the backend. On load
@@ -159,6 +160,17 @@ export const useWorkspaceStore = defineStore(
159
160
  ready.value = false
160
161
  error.value = null
161
162
  try {
163
+ // Cold-open waterfall flattening (app-startup initiative, item 8): the persisted board is
164
+ // usually known from localStorage BEFORE any request fires, and its snapshot is the app's
165
+ // heaviest payload (the ~18-read aggregate). Fetch it SPECULATIVELY in parallel with the
166
+ // workspace list + accounts instead of waiting for the list to resolve first — one fewer
167
+ // sequential round trip on the critical path. Validated for membership in
168
+ // resolveActiveBoard; a stale/removed persisted id just discards the speculative result and
169
+ // falls back to today's path. `.catch` keeps a gone-board 404 from rejecting the whole init.
170
+ const persistedId = workspaceId.value
171
+ const speculativeSnapshot = persistedId
172
+ ? api.getWorkspace(persistedId).catch(() => null)
173
+ : null
162
174
  // Accounts (an auth concept — empty in dev, which leaves boards unscoped) and the
163
175
  // workspace list are independent, so fetch them concurrently. resolveActiveBoard
164
176
  // needs both, so it still runs after.
@@ -168,25 +180,38 @@ export const useWorkspaceStore = defineStore(
168
180
  .catch(() => {}),
169
181
  api.listWorkspaces(),
170
182
  ])
183
+ markBoot('workspaces-listed')
171
184
  workspaces.value = workspaceList
172
- await resolveActiveBoard()
185
+ await resolveActiveBoard(await speculativeSnapshot)
186
+ markBoot('snapshot-hydrated')
173
187
  ready.value = true
174
188
  } catch (e) {
175
189
  error.value = e instanceof Error ? e.message : 'Failed to reach the backend.'
176
190
  }
177
191
  }
178
192
 
179
- /** Open the persisted board (aligning the active account to it), else pick/create one. */
180
- async function resolveActiveBoard() {
193
+ /**
194
+ * Open the persisted board (aligning the active account to it), else pick/create one.
195
+ *
196
+ * `prefetched` is the speculatively-fetched snapshot for the persisted board (see {@link init}):
197
+ * when it's for the SAME still-valid board we reuse it instead of re-fetching, so the cold open
198
+ * pays exactly one snapshot fetch — overlapped with the workspace list rather than after it.
199
+ */
200
+ async function resolveActiveBoard(prefetched?: WorkspaceSnapshot | null) {
181
201
  const accounts = useAccountsStore()
182
202
  if (workspaceId.value) {
183
203
  const existing = workspaces.value.find((w) => w.id === workspaceId.value)
184
204
  if (existing) {
185
205
  if (accounts.enabled && existing.accountId) accounts.activeAccountId = existing.accountId
186
- hydrate(await api.getWorkspace(existing.id))
206
+ hydrate(
207
+ prefetched && prefetched.workspace.id === existing.id
208
+ ? prefetched
209
+ : await api.getWorkspace(existing.id),
210
+ )
187
211
  return
188
212
  }
189
- // Persisted board is gone (deleted, or now another tenant's) — fall through.
213
+ // Persisted board is gone (deleted, or now another tenant's) — fall through (and discard the
214
+ // now-irrelevant speculative snapshot).
190
215
  workspaceId.value = null
191
216
  }
192
217
  const first = accountWorkspaces.value[0]
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Cold-open instrumentation (app-startup initiative, item 1). The SPA is `ssr: false`, so the whole
3
+ * "launch → usable board" path runs client-side after the bundle loads, and nothing timed the
4
+ * milestones along it. `markBoot` drops a `performance.mark` at each one plus `performance.measure`s
5
+ * so the cold-open waterfall is visible in a Playwright/browser trace:
6
+ * - one `cat-factory:open→<milestone>` measure = absolute ms from navigation start to the milestone,
7
+ * - one `cat-factory:<prev>→<milestone>` measure = the segment since the previous milestone.
8
+ *
9
+ * Milestones (fired once per cold open): `auth-ready` → `workspaces-listed` → `snapshot-hydrated`
10
+ * → `stream-connected`. It is pure instrumentation — no behaviour depends on it — and it is a no-op
11
+ * where the User Timing API is unavailable (SSR/prerender, ancient engines), so callers never guard.
12
+ */
13
+
14
+ const PREFIX = 'cat-factory'
15
+
16
+ /** Milestones already stamped this session — cold-open marks fire ONCE (later switches don't re-time). */
17
+ const seen = new Set<string>()
18
+ /** The previous milestone's mark name, so each call can measure the segment since it. */
19
+ let previousMark: string | null = null
20
+
21
+ export function markBoot(milestone: string): void {
22
+ if (seen.has(milestone)) return
23
+ if (typeof performance === 'undefined' || typeof performance.mark !== 'function') return
24
+ seen.add(milestone)
25
+ const mark = `${PREFIX}:${milestone}`
26
+ const prev = previousMark
27
+ try {
28
+ performance.mark(mark)
29
+ } catch {
30
+ return
31
+ }
32
+ // Advance the chain as soon as the mark itself lands, INDEPENDENT of the derived measures below.
33
+ // Some engines reject the numeric-`start` measure form; if that throws we still want the NEXT
34
+ // milestone to measure its segment from THIS mark rather than a stale earlier one.
35
+ previousMark = mark
36
+ try {
37
+ // Absolute time from navigation start (DOMHighResTimeStamp 0 = timeOrigin) to this milestone.
38
+ performance.measure(`${PREFIX}:open→${milestone}`, { start: 0, end: mark })
39
+ // The segment since the previous milestone — the waterfall bar.
40
+ if (prev) {
41
+ performance.measure(`${PREFIX}:${prev.slice(PREFIX.length + 1)}→${milestone}`, {
42
+ start: prev,
43
+ end: mark,
44
+ })
45
+ }
46
+ } catch {
47
+ // Derived measures unsupported on this engine; the marks still land, so the trace is usable.
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.116.6",
3
+ "version": "0.116.7",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",