@cat-factory/app 0.116.6 → 0.116.8
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/AuthGate.vue +3 -1
- package/app/components/github/AddServiceFromRepoModal.vue +167 -38
- package/app/components/github/RepoTreeBrowser.vue +56 -14
- package/app/components/layout/SideBar.vue +8 -5
- package/app/composables/useSingleFlightProbe.spec.ts +114 -0
- package/app/composables/useSingleFlightProbe.ts +70 -0
- package/app/composables/useSourceIntegration.ts +17 -1
- package/app/pages/index.vue +7 -3
- package/app/stores/documents.ts +3 -1
- package/app/stores/fragmentLibrary.ts +7 -1
- package/app/stores/github.ts +8 -1
- package/app/stores/slack.ts +7 -1
- package/app/stores/tasks.ts +3 -1
- package/app/stores/workspace.spec.ts +67 -0
- package/app/stores/workspace.ts +30 -5
- package/app/utils/bootMarks.ts +49 -0
- package/app/utils/repoPath.ts +10 -0
- package/i18n/locales/de.json +9 -5
- package/i18n/locales/en.json +15 -5
- package/i18n/locales/es.json +9 -5
- package/i18n/locales/fr.json +9 -5
- package/i18n/locales/he.json +9 -5
- package/i18n/locales/it.json +9 -5
- package/i18n/locales/ja.json +9 -5
- package/i18n/locales/pl.json +9 -5
- package/i18n/locales/tr.json +9 -5
- package/i18n/locales/uk.json +9 -5
- package/package.json +1 -1
|
@@ -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
|
|
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
|
}
|
package/app/pages/index.vue
CHANGED
|
@@ -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.
|
|
249
|
-
//
|
|
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.
|
|
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(
|
package/app/stores/documents.ts
CHANGED
|
@@ -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
|
|
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,
|
package/app/stores/github.ts
CHANGED
|
@@ -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
|
|
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,
|
package/app/stores/slack.ts
CHANGED
|
@@ -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
|
|
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,
|
package/app/stores/tasks.ts
CHANGED
|
@@ -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
|
+
})
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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
|
-
/**
|
|
180
|
-
|
|
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(
|
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise a repo-root-relative path to its canonical, slash-trimmed form.
|
|
3
|
+
*
|
|
4
|
+
* GitHub returns tree entry paths with no surrounding slashes, but a stored service
|
|
5
|
+
* `directory` may carry them, so both the monorepo directory picker and the tree
|
|
6
|
+
* browser normalise before comparing a picked/added directory against a tree entry.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeRepoPath(p: string): string {
|
|
9
|
+
return p.replace(/^\/+|\/+$/g, '')
|
|
10
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -2523,20 +2523,23 @@
|
|
|
2523
2523
|
},
|
|
2524
2524
|
"monorepoLabel": "Dies ist ein Monorepo (beherbergt mehr als einen Service)",
|
|
2525
2525
|
"monorepoDescription": "Fügen Sie mehrere Services aus einem Repo hinzu, jeder an ein Unterverzeichnis gebunden.",
|
|
2526
|
-
"monorepoBrowseHint": "Durchsuchen Sie das Repository und wählen Sie
|
|
2527
|
-
"
|
|
2528
|
-
"
|
|
2526
|
+
"monorepoBrowseHint": "Durchsuchen Sie das Repository und wählen Sie die Verzeichnisse der Services aus, die Sie hinzufügen möchten – aus jedem beliebigen Ordner. Agents, die an einem Service arbeiten, laufen innerhalb seines Unterverzeichnisses.",
|
|
2527
|
+
"selectedServices": "Ausgewählte Services",
|
|
2528
|
+
"noServicesSelected": "Noch keine Services ausgewählt. Wählen Sie oben Verzeichnisse aus.",
|
|
2529
|
+
"addServices": "{count} Service hinzufügen | {count} Services hinzufügen",
|
|
2530
|
+
"removeService": "{directory} entfernen",
|
|
2529
2531
|
"addedConfigure": "{title} hinzugefügt, konfigurieren Sie es",
|
|
2530
2532
|
"grantAccess": "Der App Zugriff auf ein Repo gewähren",
|
|
2531
2533
|
"grantAccessTitle": "Öffnen Sie die Installationseinstellungen der App, um ihr Zugriff auf ein Repository zu gewähren",
|
|
2532
2534
|
"refreshList": "Liste aktualisieren",
|
|
2533
2535
|
"done": "Fertig",
|
|
2534
2536
|
"add": "Service hinzufügen",
|
|
2535
|
-
"addAnother": "Weiteren Service hinzufügen",
|
|
2536
2537
|
"toast": {
|
|
2537
2538
|
"addedTitle": "Service hinzugefügt",
|
|
2538
2539
|
"addedDescription": "{title} ist auf dem Board, konfigurieren Sie es unten.",
|
|
2539
|
-
"addFailedTitle": "Service konnte nicht hinzugefügt werden"
|
|
2540
|
+
"addFailedTitle": "Service konnte nicht hinzugefügt werden",
|
|
2541
|
+
"servicesAddedTitle": "Services hinzugefügt",
|
|
2542
|
+
"servicesAddedDescription": "{count} Service zum Board hinzugefügt. | {count} Services zum Board hinzugefügt."
|
|
2540
2543
|
}
|
|
2541
2544
|
},
|
|
2542
2545
|
"repoTree": {
|
|
@@ -2546,6 +2549,7 @@
|
|
|
2546
2549
|
"empty": "Nichts hier.",
|
|
2547
2550
|
"select": "Auswählen",
|
|
2548
2551
|
"selected": "Ausgewählt",
|
|
2552
|
+
"added": "Hinzugefügt",
|
|
2549
2553
|
"useThisFolder": "Diesen Ordner verwenden",
|
|
2550
2554
|
"errors": {
|
|
2551
2555
|
"listDirectory": "Verzeichnis konnte nicht aufgelistet werden"
|
package/i18n/locales/en.json
CHANGED
|
@@ -2983,20 +2983,29 @@
|
|
|
2983
2983
|
},
|
|
2984
2984
|
"monorepoLabel": "This is a monorepo (hosts more than one service)",
|
|
2985
2985
|
"monorepoDescription": "Add several services from one repo, each pinned to a subdirectory.",
|
|
2986
|
-
"monorepoBrowseHint": "Browse the repository and
|
|
2987
|
-
"
|
|
2988
|
-
"
|
|
2986
|
+
"monorepoBrowseHint": "Browse the repository and select the directories of the services you want to add — from any folder. Agents working on a service run within its subdirectory.",
|
|
2987
|
+
"selectedServices": "Selected services",
|
|
2988
|
+
"noServicesSelected": "No services selected yet. Pick directories above.",
|
|
2989
|
+
"addServices": "Add {count} service | Add {count} services",
|
|
2990
|
+
"@addServices": {
|
|
2991
|
+
"description": "Count-driven button label; resolved via t(key, { count }, count) so {count} also drives the plural choice. Provide ALL plural forms your language needs (English has 2; Polish/Ukrainian need 3 - one/few/many - via the custom pluralRules in i18n.config.ts)."
|
|
2992
|
+
},
|
|
2993
|
+
"removeService": "Remove {directory}",
|
|
2989
2994
|
"addedConfigure": "{title} added, configure it",
|
|
2990
2995
|
"grantAccess": "Grant the App access to a repo",
|
|
2991
2996
|
"grantAccessTitle": "Open the App's installation settings to grant it access to a repository",
|
|
2992
2997
|
"refreshList": "Refresh list",
|
|
2993
2998
|
"done": "Done",
|
|
2994
2999
|
"add": "Add service",
|
|
2995
|
-
"addAnother": "Add another service",
|
|
2996
3000
|
"toast": {
|
|
2997
3001
|
"addedTitle": "Service added",
|
|
2998
3002
|
"addedDescription": "{title} is on the board, configure it below.",
|
|
2999
|
-
"addFailedTitle": "Could not add service"
|
|
3003
|
+
"addFailedTitle": "Could not add service",
|
|
3004
|
+
"servicesAddedTitle": "Services added",
|
|
3005
|
+
"servicesAddedDescription": "{count} service added to the board. | {count} services added to the board.",
|
|
3006
|
+
"@servicesAddedDescription": {
|
|
3007
|
+
"description": "Count-driven toast; resolved via t(key, { count }, count) so {count} also drives the plural choice. Provide ALL plural forms your language needs (Polish/Ukrainian need 3 - one/few/many)."
|
|
3008
|
+
}
|
|
3000
3009
|
}
|
|
3001
3010
|
},
|
|
3002
3011
|
"repoTree": {
|
|
@@ -3006,6 +3015,7 @@
|
|
|
3006
3015
|
"empty": "Nothing here.",
|
|
3007
3016
|
"select": "Select",
|
|
3008
3017
|
"selected": "Selected",
|
|
3018
|
+
"added": "Added",
|
|
3009
3019
|
"useThisFolder": "Use this folder",
|
|
3010
3020
|
"errors": {
|
|
3011
3021
|
"listDirectory": "Could not list directory"
|
package/i18n/locales/es.json
CHANGED
|
@@ -2894,20 +2894,23 @@
|
|
|
2894
2894
|
},
|
|
2895
2895
|
"monorepoLabel": "Es un monorepo (aloja más de un servicio)",
|
|
2896
2896
|
"monorepoDescription": "Añade varios servicios desde un repositorio, cada uno fijado a un subdirectorio.",
|
|
2897
|
-
"monorepoBrowseHint": "Explora el repositorio y
|
|
2898
|
-
"
|
|
2899
|
-
"
|
|
2897
|
+
"monorepoBrowseHint": "Explora el repositorio y selecciona los directorios de los servicios que quieres añadir, de cualquier carpeta. Los agentes que trabajen en un servicio se ejecutarán dentro de su subdirectorio.",
|
|
2898
|
+
"selectedServices": "Servicios seleccionados",
|
|
2899
|
+
"noServicesSelected": "Aún no hay servicios seleccionados. Elige directorios arriba.",
|
|
2900
|
+
"addServices": "Añadir {count} servicio | Añadir {count} servicios",
|
|
2901
|
+
"removeService": "Quitar {directory}",
|
|
2900
2902
|
"addedConfigure": "{title} añadido, configúralo",
|
|
2901
2903
|
"grantAccess": "Conceder a la App acceso a un repositorio",
|
|
2902
2904
|
"grantAccessTitle": "Abrir la configuración de instalación de la App para concederle acceso a un repositorio",
|
|
2903
2905
|
"refreshList": "Actualizar lista",
|
|
2904
2906
|
"done": "Listo",
|
|
2905
2907
|
"add": "Añadir servicio",
|
|
2906
|
-
"addAnother": "Añadir otro servicio",
|
|
2907
2908
|
"toast": {
|
|
2908
2909
|
"addedTitle": "Servicio añadido",
|
|
2909
2910
|
"addedDescription": "{title} está en el tablero, configúralo abajo.",
|
|
2910
|
-
"addFailedTitle": "No se pudo añadir el servicio"
|
|
2911
|
+
"addFailedTitle": "No se pudo añadir el servicio",
|
|
2912
|
+
"servicesAddedTitle": "Servicios añadidos",
|
|
2913
|
+
"servicesAddedDescription": "{count} servicio añadido al tablero. | {count} servicios añadidos al tablero."
|
|
2911
2914
|
},
|
|
2912
2915
|
"repoType": "Tipo de repositorio",
|
|
2913
2916
|
"repoTypeHint": "Qué es este repositorio: un servicio backend, una aplicación frontend, una biblioteca compartida o un repositorio de documentación (solo documentos/spikes)."
|
|
@@ -2919,6 +2922,7 @@
|
|
|
2919
2922
|
"empty": "No hay nada aquí.",
|
|
2920
2923
|
"select": "Seleccionar",
|
|
2921
2924
|
"selected": "Seleccionado",
|
|
2925
|
+
"added": "Añadido",
|
|
2922
2926
|
"useThisFolder": "Usar esta carpeta",
|
|
2923
2927
|
"errors": {
|
|
2924
2928
|
"listDirectory": "No se pudo listar el directorio"
|
package/i18n/locales/fr.json
CHANGED
|
@@ -2894,20 +2894,23 @@
|
|
|
2894
2894
|
},
|
|
2895
2895
|
"monorepoLabel": "Ceci est un monorepo (héberge plusieurs services)",
|
|
2896
2896
|
"monorepoDescription": "Ajoutez plusieurs services depuis un même dépôt, chacun rattaché à un sous-répertoire.",
|
|
2897
|
-
"monorepoBrowseHint": "Parcourez le dépôt et
|
|
2898
|
-
"
|
|
2899
|
-
"
|
|
2897
|
+
"monorepoBrowseHint": "Parcourez le dépôt et sélectionnez les répertoires des services que vous voulez ajouter, depuis n'importe quel dossier. Les agents travaillant sur un service s'exécutent dans son sous-répertoire.",
|
|
2898
|
+
"selectedServices": "Services sélectionnés",
|
|
2899
|
+
"noServicesSelected": "Aucun service sélectionné pour le moment. Choisissez des répertoires ci-dessus.",
|
|
2900
|
+
"addServices": "Ajouter {count} service | Ajouter {count} services",
|
|
2901
|
+
"removeService": "Retirer {directory}",
|
|
2900
2902
|
"addedConfigure": "{title} ajouté, configurez-le",
|
|
2901
2903
|
"grantAccess": "Accorder à l'App l'accès à un dépôt",
|
|
2902
2904
|
"grantAccessTitle": "Ouvrir les paramètres d'installation de l'App pour lui accorder l'accès à un dépôt",
|
|
2903
2905
|
"refreshList": "Actualiser la liste",
|
|
2904
2906
|
"done": "Terminé",
|
|
2905
2907
|
"add": "Ajouter le service",
|
|
2906
|
-
"addAnother": "Ajouter un autre service",
|
|
2907
2908
|
"toast": {
|
|
2908
2909
|
"addedTitle": "Service ajouté",
|
|
2909
2910
|
"addedDescription": "{title} est sur le tableau, configurez-le ci-dessous.",
|
|
2910
|
-
"addFailedTitle": "Impossible d'ajouter le service"
|
|
2911
|
+
"addFailedTitle": "Impossible d'ajouter le service",
|
|
2912
|
+
"servicesAddedTitle": "Services ajoutés",
|
|
2913
|
+
"servicesAddedDescription": "{count} service ajouté au tableau. | {count} services ajoutés au tableau."
|
|
2911
2914
|
},
|
|
2912
2915
|
"repoType": "Type de dépôt",
|
|
2913
2916
|
"repoTypeHint": "Ce qu'est ce dépôt : un service backend, une application frontend, une bibliothèque partagée ou un dépôt de documentation (documents/spikes uniquement)."
|
|
@@ -2919,6 +2922,7 @@
|
|
|
2919
2922
|
"empty": "Rien ici.",
|
|
2920
2923
|
"select": "Sélectionner",
|
|
2921
2924
|
"selected": "Sélectionné",
|
|
2925
|
+
"added": "Ajouté",
|
|
2922
2926
|
"useThisFolder": "Utiliser ce dossier",
|
|
2923
2927
|
"errors": {
|
|
2924
2928
|
"listDirectory": "Impossible de lister le répertoire"
|
package/i18n/locales/he.json
CHANGED
|
@@ -2905,20 +2905,23 @@
|
|
|
2905
2905
|
},
|
|
2906
2906
|
"monorepoLabel": "זהו מונורפו (מארח יותר משירות אחד)",
|
|
2907
2907
|
"monorepoDescription": "הוסף כמה שירותים ממאגר אחד, כל אחד מוצמד לתת-ספרייה.",
|
|
2908
|
-
"monorepoBrowseHint": "עיין במאגר ובחר את
|
|
2909
|
-
"
|
|
2910
|
-
"
|
|
2908
|
+
"monorepoBrowseHint": "עיין במאגר ובחר את הספריות של השירותים שברצונך להוסיף — מכל תיקייה. סוכנים העובדים על שירות ירוצו בתוך תת-הספרייה שלו.",
|
|
2909
|
+
"selectedServices": "שירותים נבחרים",
|
|
2910
|
+
"noServicesSelected": "עדיין לא נבחרו שירותים. בחר ספריות למעלה.",
|
|
2911
|
+
"addServices": "הוסף שירות {count} | הוסף {count} שירותים",
|
|
2912
|
+
"removeService": "הסר {directory}",
|
|
2911
2913
|
"addedConfigure": "{title} נוסף, הגדר אותו",
|
|
2912
2914
|
"grantAccess": "הענק לאפליקציה גישה למאגר",
|
|
2913
2915
|
"grantAccessTitle": "פתח את הגדרות ההתקנה של האפליקציה כדי להעניק לה גישה למאגר",
|
|
2914
2916
|
"refreshList": "רענן רשימה",
|
|
2915
2917
|
"done": "סיום",
|
|
2916
2918
|
"add": "הוסף שירות",
|
|
2917
|
-
"addAnother": "הוסף שירות נוסף",
|
|
2918
2919
|
"toast": {
|
|
2919
2920
|
"addedTitle": "השירות נוסף",
|
|
2920
2921
|
"addedDescription": "{title} על הלוח, הגדר אותו למטה.",
|
|
2921
|
-
"addFailedTitle": "לא ניתן היה להוסיף שירות"
|
|
2922
|
+
"addFailedTitle": "לא ניתן היה להוסיף שירות",
|
|
2923
|
+
"servicesAddedTitle": "השירותים נוספו",
|
|
2924
|
+
"servicesAddedDescription": "שירות {count} נוסף ללוח. | {count} שירותים נוספו ללוח."
|
|
2922
2925
|
},
|
|
2923
2926
|
"repoType": "סוג המאגר",
|
|
2924
2927
|
"repoTypeHint": "מה המאגר הזה: שירות בק-אנד, אפליקציית פרונט-אנד, ספרייה משותפת או מאגר תיעוד (מסמכים/ספייקים בלבד)."
|
|
@@ -2930,6 +2933,7 @@
|
|
|
2930
2933
|
"empty": "אין כאן כלום.",
|
|
2931
2934
|
"select": "בחר",
|
|
2932
2935
|
"selected": "נבחר",
|
|
2936
|
+
"added": "נוסף",
|
|
2933
2937
|
"useThisFolder": "השתמש בתיקייה זו",
|
|
2934
2938
|
"errors": {
|
|
2935
2939
|
"listDirectory": "לא ניתן היה לרשום את הספרייה"
|