@cat-factory/app 0.259.3 → 0.261.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.
- package/README.md +34 -0
- package/app/components/auth/LoginScreen.vue +4 -3
- package/app/components/board/BoardCanvas.vue +9 -1
- package/app/components/board/TaskDependencyEdges.vue +35 -26
- package/app/components/github/AddServiceFromRepoModal.vue +132 -29
- package/app/components/settings/McpAuthorizeScreen.vue +262 -0
- package/app/composables/api/mcpAuthorization.ts +30 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBoardActivity.ts +111 -0
- package/app/composables/useSettlingRaf.ts +32 -0
- package/app/composables/useTaskExpansion.ts +29 -11
- package/app/pages/mcp-authorize.vue +7 -0
- package/app/stores/auth/session.ts +14 -2
- package/app/stores/board/placement.ts +12 -3
- package/app/stores/board.spec.ts +22 -2
- package/app/utils/edgeSegments.spec.ts +49 -0
- package/app/utils/edgeSegments.ts +49 -0
- package/app/utils/monorepoImport.spec.ts +120 -0
- package/app/utils/monorepoImport.ts +154 -0
- package/app/utils/postSignIn.spec.ts +29 -0
- package/app/utils/postSignIn.ts +29 -0
- package/app/utils/settlingLoop.spec.ts +236 -0
- package/app/utils/settlingLoop.ts +101 -0
- package/i18n/locales/de.json +45 -1
- package/i18n/locales/en.json +45 -1
- package/i18n/locales/es.json +45 -1
- package/i18n/locales/fr.json +45 -1
- package/i18n/locales/he.json +45 -1
- package/i18n/locales/it.json +45 -1
- package/i18n/locales/ja.json +45 -1
- package/i18n/locales/pl.json +45 -1
- package/i18n/locales/tr.json +45 -1
- package/i18n/locales/uk.json +45 -1
- package/package.json +2 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
MCP_AUTHORIZATION_REQUEST_INVALID,
|
|
5
|
+
PUBLIC_API_SCOPES,
|
|
6
|
+
type PublicApiScope,
|
|
7
|
+
} from '@cat-factory/contracts'
|
|
8
|
+
import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
|
|
9
|
+
|
|
10
|
+
// The consent screen an MCP host's authorization request lands on
|
|
11
|
+
// (`/mcp-authorize?request=…`), reached by a redirect from `GET /oauth/authorize`.
|
|
12
|
+
//
|
|
13
|
+
// A page in the APP rather than a screen the backend renders, and that is the security shape of
|
|
14
|
+
// this flow: the authorization endpoint is a top-level navigation a third party triggers, carrying
|
|
15
|
+
// no bearer token, so a screen served there could never say WHO is approving. Here the session is
|
|
16
|
+
// the app's own, and the two calls this page makes are ordinary gated API where the board choice
|
|
17
|
+
// and its `secrets.manage` check actually run.
|
|
18
|
+
//
|
|
19
|
+
// Not a public route: an expired session renders the login screen on this same URL, and once the
|
|
20
|
+
// person signs in the query string is still here and the flow continues (`postSignInUrl`, which
|
|
21
|
+
// LoginScreen reloads to, exists to keep it). That is correct rather than a gap, and it is also how
|
|
22
|
+
// an SSO deployment gets its identity provider into a flow that otherwise has no idea who anyone
|
|
23
|
+
// is.
|
|
24
|
+
|
|
25
|
+
const api = useApi()
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
type Screen = 'loading' | 'deciding' | 'submitting' | 'failed'
|
|
29
|
+
|
|
30
|
+
const screen = ref<Screen>('loading')
|
|
31
|
+
const detail = ref<string | null>(null)
|
|
32
|
+
/**
|
|
33
|
+
* A refusal that did NOT consume the request, shown beside the choices rather than instead of
|
|
34
|
+
* them. See `decide`: the two failures are answered differently because only one of them ends the
|
|
35
|
+
* flow.
|
|
36
|
+
*/
|
|
37
|
+
const decisionError = ref<string | null>(null)
|
|
38
|
+
const clientName = ref('')
|
|
39
|
+
const redirectOrigin = ref('')
|
|
40
|
+
const workspaces = ref<{ label: string; value: string }[]>([])
|
|
41
|
+
const workspaceId = ref<string | undefined>(undefined)
|
|
42
|
+
/**
|
|
43
|
+
* Starts at the FLOOR of the ladder and is replaced by the server's `defaultScope` once the request
|
|
44
|
+
* resolves. The screen never preselects from the host's own ask: an unauthenticated registration
|
|
45
|
+
* can name any scope, so the server clamps it (`consentDefaultScope`) and this page renders what it
|
|
46
|
+
* was given. Least privilege before that answer arrives, in case a render ever beats it.
|
|
47
|
+
*/
|
|
48
|
+
const scope = ref<PublicApiScope>('read')
|
|
49
|
+
/** What the host asked for, when the server preselected something else. Shown, never applied. */
|
|
50
|
+
const requestedScope = ref<PublicApiScope | null>(null)
|
|
51
|
+
|
|
52
|
+
const sealedRequest = computed(() =>
|
|
53
|
+
typeof window === 'undefined'
|
|
54
|
+
? ''
|
|
55
|
+
: (new URLSearchParams(window.location.search).get('request') ?? ''),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The ladder, as choices. Every rung is offered rather than a curated subset: the rungs are what
|
|
60
|
+
* the surface itself enforces, and hiding one would leave a host that genuinely needs it unable to
|
|
61
|
+
* be granted it from the screen built for granting.
|
|
62
|
+
*/
|
|
63
|
+
const scopeItems = computed(() =>
|
|
64
|
+
PUBLIC_API_SCOPES.map((value) => ({
|
|
65
|
+
value,
|
|
66
|
+
label: t(`mcpAuthorize.scope.${value}.label`),
|
|
67
|
+
description: t(`mcpAuthorize.scope.${value}.description`),
|
|
68
|
+
})),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
onMounted(async () => {
|
|
72
|
+
if (!sealedRequest.value) {
|
|
73
|
+
screen.value = 'failed'
|
|
74
|
+
detail.value = t('mcpAuthorize.error.noRequest')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const [request, boards] = await Promise.all([
|
|
79
|
+
api.describeMcpAuthorization(sealedRequest.value),
|
|
80
|
+
api.listWorkspaces(),
|
|
81
|
+
])
|
|
82
|
+
clientName.value = request.clientName
|
|
83
|
+
redirectOrigin.value = request.redirectOrigin
|
|
84
|
+
scope.value = request.defaultScope
|
|
85
|
+
// Only worth saying when the two differ: identical values would be a line telling a person
|
|
86
|
+
// that the thing in front of them is the thing in front of them.
|
|
87
|
+
requestedScope.value =
|
|
88
|
+
request.requestedScope && request.requestedScope !== request.defaultScope
|
|
89
|
+
? request.requestedScope
|
|
90
|
+
: null
|
|
91
|
+
workspaces.value = boards.map((board) => ({ label: board.name, value: board.id }))
|
|
92
|
+
workspaceId.value = workspaces.value[0]?.value
|
|
93
|
+
screen.value = 'deciding'
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Terminal whatever the cause: with no request to describe there is nothing to decide, and the
|
|
96
|
+
// person is told to start again from the host, which is the only place a new one comes from.
|
|
97
|
+
screen.value = 'failed'
|
|
98
|
+
detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
async function decide(decision: 'approve' | 'deny') {
|
|
103
|
+
if (decision === 'approve' && !workspaceId.value) return
|
|
104
|
+
screen.value = 'submitting'
|
|
105
|
+
decisionError.value = null
|
|
106
|
+
try {
|
|
107
|
+
const result = await api.decideMcpAuthorization(
|
|
108
|
+
decision === 'approve'
|
|
109
|
+
? {
|
|
110
|
+
decision,
|
|
111
|
+
request: sealedRequest.value,
|
|
112
|
+
workspaceId: workspaceId.value as string,
|
|
113
|
+
scope: scope.value,
|
|
114
|
+
}
|
|
115
|
+
: { decision, request: sealedRequest.value },
|
|
116
|
+
)
|
|
117
|
+
// A full navigation, never a router push: the destination is the HOST's own callback, which is
|
|
118
|
+
// waiting for a browser to arrive at it with the code on the query string.
|
|
119
|
+
if (typeof window !== 'undefined') window.location.assign(result.redirectTo)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// Two outcomes, because two things can be wrong and only one of them ends the flow. The sealed
|
|
122
|
+
// request gone is TERMINAL: nothing on this page can mint another, so it says so and offers the
|
|
123
|
+
// way out. Anything else (this board is one the person cannot mint a key on, it disappeared,
|
|
124
|
+
// the deployment hiccuped) leaves the request valid and this screen the only place the decision
|
|
125
|
+
// can be made, so dropping to a dead end over it would strand a person who has a board they
|
|
126
|
+
// COULD have picked one dropdown away.
|
|
127
|
+
if (apiErrorReason(e) === MCP_AUTHORIZATION_REQUEST_INVALID) {
|
|
128
|
+
screen.value = 'failed'
|
|
129
|
+
detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
screen.value = 'deciding'
|
|
133
|
+
decisionError.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.failed')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function backToApp() {
|
|
138
|
+
if (typeof window !== 'undefined') window.location.assign('/')
|
|
139
|
+
}
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<div
|
|
144
|
+
class="flex min-h-screen w-screen items-center justify-center bg-slate-950 p-4 text-slate-100"
|
|
145
|
+
data-testid="mcp-authorize"
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
149
|
+
>
|
|
150
|
+
<template v-if="screen === 'loading'">
|
|
151
|
+
<UIcon name="i-lucide-loader" class="mx-auto h-10 w-10 animate-spin text-indigo-400" />
|
|
152
|
+
</template>
|
|
153
|
+
|
|
154
|
+
<template v-else-if="screen === 'failed'">
|
|
155
|
+
<UIcon name="i-lucide-alert-triangle" class="mx-auto mb-3 h-10 w-10 text-red-400" />
|
|
156
|
+
<h1
|
|
157
|
+
class="mb-1 text-center text-lg font-semibold text-white"
|
|
158
|
+
data-testid="mcp-authorize-failed"
|
|
159
|
+
>
|
|
160
|
+
{{ t('mcpAuthorize.error.title') }}
|
|
161
|
+
</h1>
|
|
162
|
+
<p class="mb-6 text-center text-sm break-words text-slate-400">{{ detail }}</p>
|
|
163
|
+
<UButton block color="neutral" variant="subtle" @click="backToApp">
|
|
164
|
+
{{ t('mcpAuthorize.back') }}
|
|
165
|
+
</UButton>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<template v-else>
|
|
169
|
+
<UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
170
|
+
<h1 class="mb-1 text-center text-lg font-semibold text-white">
|
|
171
|
+
{{ t('mcpAuthorize.title', { client: clientName }) }}
|
|
172
|
+
</h1>
|
|
173
|
+
<!-- The origin is the one fact here an attacker cannot choose: it was matched against what
|
|
174
|
+
the client registered before this screen was ever reached. The name beside it is a
|
|
175
|
+
stranger's own words, so the copy presents it as a claim rather than as identity.
|
|
176
|
+
The copy reads "It says it is {client}, and …" in every locale, so BOTH holes have to
|
|
177
|
+
be filled: an unpassed `client` renders a sentence naming nobody, on the one screen
|
|
178
|
+
whose whole subject is who is asking. -->
|
|
179
|
+
<p class="mb-6 text-center text-sm text-slate-400">
|
|
180
|
+
{{ t('mcpAuthorize.subtitle', { client: clientName, origin: redirectOrigin }) }}
|
|
181
|
+
</p>
|
|
182
|
+
|
|
183
|
+
<div v-if="!workspaces.length" class="mb-6 text-center text-sm text-amber-300">
|
|
184
|
+
{{ t('mcpAuthorize.noWorkspaces') }}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<template v-else>
|
|
188
|
+
<UFormField :label="t('mcpAuthorize.workspace.label')" class="mb-4">
|
|
189
|
+
<USelect
|
|
190
|
+
v-model="workspaceId"
|
|
191
|
+
:items="workspaces"
|
|
192
|
+
value-key="value"
|
|
193
|
+
class="w-full"
|
|
194
|
+
data-testid="mcp-authorize-workspace"
|
|
195
|
+
/>
|
|
196
|
+
</UFormField>
|
|
197
|
+
|
|
198
|
+
<UFormField
|
|
199
|
+
:label="t('mcpAuthorize.scopeLabel')"
|
|
200
|
+
:description="t('mcpAuthorize.scopeHint')"
|
|
201
|
+
:class="requestedScope ? 'mb-2' : 'mb-6'"
|
|
202
|
+
>
|
|
203
|
+
<URadioGroup
|
|
204
|
+
v-model="scope"
|
|
205
|
+
:items="scopeItems"
|
|
206
|
+
value-key="value"
|
|
207
|
+
data-testid="mcp-authorize-scope"
|
|
208
|
+
/>
|
|
209
|
+
</UFormField>
|
|
210
|
+
|
|
211
|
+
<!-- Only when the host asked for something other than what is preselected. Anyone can
|
|
212
|
+
register a client and ask for `admin`, so the ask is reported as a fact ABOUT the
|
|
213
|
+
host rather than acted on: raising the grant stays a thing a person does. -->
|
|
214
|
+
<p
|
|
215
|
+
v-if="requestedScope"
|
|
216
|
+
class="mb-6 text-xs text-amber-300"
|
|
217
|
+
data-testid="mcp-authorize-requested-scope"
|
|
218
|
+
>
|
|
219
|
+
{{
|
|
220
|
+
t('mcpAuthorize.requestedScope', {
|
|
221
|
+
client: clientName,
|
|
222
|
+
scope: t(`mcpAuthorize.scope.${requestedScope}.label`),
|
|
223
|
+
})
|
|
224
|
+
}}
|
|
225
|
+
</p>
|
|
226
|
+
</template>
|
|
227
|
+
|
|
228
|
+
<p
|
|
229
|
+
v-if="decisionError"
|
|
230
|
+
class="mb-4 text-center text-sm break-words text-red-400"
|
|
231
|
+
data-testid="mcp-authorize-decision-error"
|
|
232
|
+
>
|
|
233
|
+
{{ decisionError }}
|
|
234
|
+
</p>
|
|
235
|
+
|
|
236
|
+
<div class="flex gap-2">
|
|
237
|
+
<UButton
|
|
238
|
+
block
|
|
239
|
+
color="neutral"
|
|
240
|
+
variant="subtle"
|
|
241
|
+
:disabled="screen === 'submitting'"
|
|
242
|
+
@click="decide('deny')"
|
|
243
|
+
>
|
|
244
|
+
{{ t('mcpAuthorize.deny') }}
|
|
245
|
+
</UButton>
|
|
246
|
+
<UButton
|
|
247
|
+
block
|
|
248
|
+
color="primary"
|
|
249
|
+
:loading="screen === 'submitting'"
|
|
250
|
+
:disabled="!workspaceId"
|
|
251
|
+
data-testid="mcp-authorize-approve"
|
|
252
|
+
@click="decide('approve')"
|
|
253
|
+
>
|
|
254
|
+
{{ t('mcpAuthorize.approve') }}
|
|
255
|
+
</UButton>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<p class="mt-4 text-center text-xs text-slate-500">{{ t('mcpAuthorize.revokeHint') }}</p>
|
|
259
|
+
</template>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decideMcpAuthorizationContract,
|
|
3
|
+
describeMcpAuthorizationContract,
|
|
4
|
+
type McpAuthorizationDecision,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The consent screen an MCP host's authorization request lands on.
|
|
10
|
+
*
|
|
11
|
+
* The mirror image of the tool-server OAuth calls beside it: those connect THIS deployment to
|
|
12
|
+
* someone else's MCP server, these let someone else's host connect to this one. Neither is
|
|
13
|
+
* workspace-prefixed, and for opposite reasons: there the board is sealed into the vendor's state,
|
|
14
|
+
* here it is what the person on the screen is choosing.
|
|
15
|
+
*
|
|
16
|
+
* Both are POSTs, the read included. The sealed request is a value the page carries rather than an
|
|
17
|
+
* id it looks up, and a query string would write it into browser history and every log in between.
|
|
18
|
+
*/
|
|
19
|
+
export function mcpAuthorizationApi({ send }: ApiContext) {
|
|
20
|
+
return {
|
|
21
|
+
describeMcpAuthorization: (request: string) =>
|
|
22
|
+
send(describeMcpAuthorizationContract, { body: { request } }),
|
|
23
|
+
|
|
24
|
+
// Answers with WHERE to send the browser, rather than redirecting: a 302 on a `fetch` is
|
|
25
|
+
// followed by the browser without this page seeing it, which would deliver the host's callback
|
|
26
|
+
// an XHR instead of the navigation it is waiting for.
|
|
27
|
+
decideMcpAuthorization: (body: McpAuthorizationDecision) =>
|
|
28
|
+
send(decideMcpAuthorizationContract, { body }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -35,6 +35,7 @@ import { modelsApi } from './api/models'
|
|
|
35
35
|
import { notificationsApi } from './api/notifications'
|
|
36
36
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
37
37
|
import { capabilityCredentialsApi } from './api/capabilityCredentials'
|
|
38
|
+
import { mcpAuthorizationApi } from './api/mcpAuthorization'
|
|
38
39
|
import { toolServersApi } from './api/toolServers'
|
|
39
40
|
import { preflightsApi } from './api/preflights'
|
|
40
41
|
import { presetsApi } from './api/presets'
|
|
@@ -161,6 +162,7 @@ export function useApi() {
|
|
|
161
162
|
...testSecretsApi(ctx),
|
|
162
163
|
...packageRegistriesApi(ctx),
|
|
163
164
|
...capabilityCredentialsApi(ctx),
|
|
165
|
+
...mcpAuthorizationApi(ctx),
|
|
164
166
|
...toolServersApi(ctx),
|
|
165
167
|
...previewApi(ctx),
|
|
166
168
|
...environmentsApi(ctx),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The board's shared "something may have moved" pulse.
|
|
5
|
+
*
|
|
6
|
+
* The two DOM-measuring drivers on the canvas (dependency edges, task expansion) cannot ask
|
|
7
|
+
* the DOM "did anything change since last frame" without doing the measurement that IS the
|
|
8
|
+
* cost, so each used to measure unconditionally every frame. This publishes the signals that
|
|
9
|
+
* can START a visible change instead; the drivers pair it with `useSettlingRaf`, which
|
|
10
|
+
* carries each wake through the animation that follows and parks once the output holds still.
|
|
11
|
+
*
|
|
12
|
+
* The signals are deliberately coarse. A pulse that fires when nothing moved costs a handful
|
|
13
|
+
* of frames; one that fails to fire leaves a stale arrow on screen, so this errs toward
|
|
14
|
+
* firing:
|
|
15
|
+
*
|
|
16
|
+
* - a `MutationObserver` over the canvas subtree, watching structure plus `style` / `class`.
|
|
17
|
+
* That is every Vue-driven render change on the board, Vue Flow's own pan/zoom transform
|
|
18
|
+
* included. Attribute changes the drivers themselves write (`x1`/`y1` on the edge overlay)
|
|
19
|
+
* are outside the filter, so a driver cannot pulse itself awake forever.
|
|
20
|
+
* - a `ResizeObserver` on the canvas, plus window `resize`: layout changes with no mutation.
|
|
21
|
+
* - pointer, wheel and scroll gestures on the canvas: the user moving something.
|
|
22
|
+
*
|
|
23
|
+
* What it does NOT catch is a reflow with no mutation and no gesture, such as a late-loading
|
|
24
|
+
* image or font resizing a card. Those settle on the next pulse of any kind.
|
|
25
|
+
*/
|
|
26
|
+
export type BoardActivity = {
|
|
27
|
+
/** Subscribe to the pulse. Returns the unsubscribe. */
|
|
28
|
+
subscribe: (onPulse: () => void) => () => void
|
|
29
|
+
/** Fire the pulse from a signal the observers above cannot see. */
|
|
30
|
+
pulse: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const boardActivityKey: InjectionKey<BoardActivity> = Symbol('boardActivity')
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Installs the signal sources on the board canvas element and provides the pulse to the
|
|
37
|
+
* canvas's descendants. Returns it too, because a component cannot inject what it provides.
|
|
38
|
+
*/
|
|
39
|
+
export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardActivity {
|
|
40
|
+
const subscribers = new Set<() => void>()
|
|
41
|
+
const pulse = () => {
|
|
42
|
+
for (const onPulse of subscribers) onPulse()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const activity: BoardActivity = {
|
|
46
|
+
subscribe(onPulse) {
|
|
47
|
+
subscribers.add(onPulse)
|
|
48
|
+
return () => subscribers.delete(onPulse)
|
|
49
|
+
},
|
|
50
|
+
pulse,
|
|
51
|
+
}
|
|
52
|
+
provide(boardActivityKey, activity)
|
|
53
|
+
|
|
54
|
+
const mutations = new MutationObserver(pulse)
|
|
55
|
+
const resizes = new ResizeObserver(pulse)
|
|
56
|
+
// `scroll` does not bubble, so it is caught in the capture phase; the gestures are
|
|
57
|
+
// passive listeners because the pulse never wants to cancel one.
|
|
58
|
+
const gestures = [
|
|
59
|
+
'pointerdown',
|
|
60
|
+
'pointermove',
|
|
61
|
+
'pointerup',
|
|
62
|
+
'pointerleave',
|
|
63
|
+
'wheel',
|
|
64
|
+
'scroll',
|
|
65
|
+
] as const
|
|
66
|
+
const gestureOptions = { capture: true, passive: true }
|
|
67
|
+
|
|
68
|
+
onMounted(() => {
|
|
69
|
+
// The canvas binds this ref to its own root, so by mount it is always set; the narrowing is
|
|
70
|
+
// for the nullable template-ref type rather than a case that happens.
|
|
71
|
+
const el = container.value
|
|
72
|
+
if (!el) return
|
|
73
|
+
mutations.observe(el, {
|
|
74
|
+
childList: true,
|
|
75
|
+
subtree: true,
|
|
76
|
+
attributes: true,
|
|
77
|
+
attributeFilter: ['style', 'class'],
|
|
78
|
+
})
|
|
79
|
+
resizes.observe(el)
|
|
80
|
+
for (const type of gestures) el.addEventListener(type, pulse, gestureOptions)
|
|
81
|
+
window.addEventListener('resize', pulse)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
onBeforeUnmount(() => {
|
|
85
|
+
mutations.disconnect()
|
|
86
|
+
resizes.disconnect()
|
|
87
|
+
const el = container.value
|
|
88
|
+
for (const type of gestures) el?.removeEventListener(type, pulse, gestureOptions)
|
|
89
|
+
window.removeEventListener('resize', pulse)
|
|
90
|
+
subscribers.clear()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return activity
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Keeps `onPulse` subscribed to a pulse the caller already holds, for the component's lifetime. */
|
|
97
|
+
export function onBoardActivity(activity: BoardActivity, onPulse: () => void): void {
|
|
98
|
+
onBeforeUnmount(activity.subscribe(onPulse))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The same, for a descendant of the canvas that reads the pulse by injection. Throws when used
|
|
103
|
+
* outside the board canvas: a driver that silently subscribed to nothing would measure once and
|
|
104
|
+
* then freeze, which reads as a layout bug rather than the wiring one it is. The canvas itself
|
|
105
|
+
* cannot inject what it provides, so it passes the returned pulse to `onBoardActivity` instead.
|
|
106
|
+
*/
|
|
107
|
+
export function useBoardActivity(onPulse: () => void): void {
|
|
108
|
+
const activity = inject(boardActivityKey, null)
|
|
109
|
+
if (!activity) throw new Error('useBoardActivity() requires a board canvas ancestor')
|
|
110
|
+
onBoardActivity(activity, onPulse)
|
|
111
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted } from 'vue'
|
|
2
|
+
import { createSettlingLoop, type SettlingLoop } from '~/utils/settlingLoop'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vue lifecycle wrapper around {@link createSettlingLoop}: an animation-frame loop that runs
|
|
6
|
+
* while `compute()` keeps changing something and parks once it settles. The caller wakes it
|
|
7
|
+
* with the returned `poke`, wired to whatever signals can start a change (see
|
|
8
|
+
* `useBoardActivity` for the board's shared set).
|
|
9
|
+
*
|
|
10
|
+
* `compute` MUST report honestly whether it changed anything: returning `true` unconditionally
|
|
11
|
+
* turns this back into the unconditional 60fps loop it replaced.
|
|
12
|
+
*/
|
|
13
|
+
export function useSettlingRaf(
|
|
14
|
+
compute: () => boolean,
|
|
15
|
+
options: { settleFrames?: number } = {},
|
|
16
|
+
): Pick<SettlingLoop, 'poke'> {
|
|
17
|
+
const loop = createSettlingLoop({
|
|
18
|
+
compute,
|
|
19
|
+
settleFrames: options.settleFrames,
|
|
20
|
+
scheduler: {
|
|
21
|
+
schedule: (run) => requestAnimationFrame(run),
|
|
22
|
+
cancel: (handle) => cancelAnimationFrame(handle),
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// The first frame runs on mount: the board arrives with blocks already laid out, and
|
|
27
|
+
// nothing would poke a loop that had never measured anything.
|
|
28
|
+
onMounted(loop.poke)
|
|
29
|
+
onBeforeUnmount(loop.stop)
|
|
30
|
+
|
|
31
|
+
return { poke: loop.poke }
|
|
32
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import { onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
-
import { useRafFn } from '@vueuse/core'
|
|
4
3
|
import { lodAtLeast } from '~/composables/useSemanticZoom'
|
|
4
|
+
import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
|
|
5
|
+
import { useSettlingRaf } from '~/composables/useSettlingRaf'
|
|
5
6
|
import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
|
|
6
7
|
|
|
7
8
|
function intersects(a: Rect, b: Rect) {
|
|
@@ -33,8 +34,12 @@ function sameSet(a: Set<string>, b: Set<string>) {
|
|
|
33
34
|
*
|
|
34
35
|
* Only tasks with a running pipeline (steps to show) are candidates for either grant — a
|
|
35
36
|
* task that wouldn't expand never blocks a neighbour and never lifts an empty card.
|
|
37
|
+
*
|
|
38
|
+
* Deciding costs a rect per candidate plus an `elementFromPoint`, so it runs only while the
|
|
39
|
+
* board is moving: the canvas activity pulse wakes it and `useSettlingRaf` parks it again once
|
|
40
|
+
* the two grants stop changing.
|
|
36
41
|
*/
|
|
37
|
-
export function useTaskExpansion(container: Ref<HTMLElement | null
|
|
42
|
+
export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: BoardActivity) {
|
|
38
43
|
const board = useBoardStore()
|
|
39
44
|
const execution = useExecutionStore()
|
|
40
45
|
const ui = useUiStore()
|
|
@@ -79,21 +84,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
79
84
|
return id
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
/** Re-decide both grants; reports whether either of them changed. */
|
|
88
|
+
function recompute(): boolean {
|
|
83
89
|
// Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
|
|
84
90
|
// zoom gate below — resolving it after would collapse the hovered card the moment the
|
|
85
91
|
// user zoomed back out past the `steps` band.
|
|
86
92
|
const hovered = hoveredTaskId()
|
|
87
|
-
|
|
93
|
+
let changed = false
|
|
94
|
+
if (store.hoveredId !== hovered) {
|
|
95
|
+
store.setHovered(hovered)
|
|
96
|
+
changed = true
|
|
97
|
+
}
|
|
88
98
|
|
|
89
99
|
// The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
|
|
90
100
|
// only; clear its grants otherwise. The hover grant above stands on its own.
|
|
91
101
|
if (!lodAtLeast(ui.lod, 'steps')) {
|
|
92
|
-
if (store.allowed.size)
|
|
93
|
-
|
|
102
|
+
if (store.allowed.size) {
|
|
103
|
+
store.setAllowed(new Set())
|
|
104
|
+
changed = true
|
|
105
|
+
}
|
|
106
|
+
return changed
|
|
94
107
|
}
|
|
95
108
|
const view = container.value?.getBoundingClientRect()
|
|
96
|
-
if (!view) return
|
|
109
|
+
if (!view) return changed
|
|
97
110
|
const cx = view.left + view.width / 2
|
|
98
111
|
const cy = view.top + view.height / 2
|
|
99
112
|
|
|
@@ -145,19 +158,24 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
145
158
|
next.add(c.id)
|
|
146
159
|
claimed.push(c.rect)
|
|
147
160
|
}
|
|
148
|
-
if (!sameSet(next, store.allowed))
|
|
161
|
+
if (!sameSet(next, store.allowed)) {
|
|
162
|
+
store.setAllowed(next)
|
|
163
|
+
changed = true
|
|
164
|
+
}
|
|
165
|
+
return changed
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
const {
|
|
168
|
+
const { poke } = useSettlingRaf(recompute)
|
|
169
|
+
// The pointer listeners below only record where the pointer IS; the pulse (which watches the
|
|
170
|
+
// same gestures) is what schedules the frame that acts on it.
|
|
171
|
+
onBoardActivity(activity, poke)
|
|
152
172
|
onMounted(() => {
|
|
153
173
|
store.setDriverActive(true)
|
|
154
174
|
const el = container.value
|
|
155
175
|
el?.addEventListener('pointermove', onPointerMove)
|
|
156
176
|
el?.addEventListener('pointerleave', onPointerLeave)
|
|
157
|
-
resume()
|
|
158
177
|
})
|
|
159
178
|
onBeforeUnmount(() => {
|
|
160
|
-
pause()
|
|
161
179
|
const el = container.value
|
|
162
180
|
el?.removeEventListener('pointermove', onPointerMove)
|
|
163
181
|
el?.removeEventListener('pointerleave', onPointerLeave)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import type { AuthUser } from '~/types/domain'
|
|
3
|
+
import { postSignInUrl } from '~/utils/postSignIn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Shared reactive state + injected dependencies the auth-store sign-in factory closes over.
|
|
@@ -24,9 +25,20 @@ export interface AuthSessionContext {
|
|
|
24
25
|
export function createAuthSessionActions(ctx: AuthSessionContext) {
|
|
25
26
|
const { api, apiBase, token, user, autoLoginProvider } = ctx
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Build a post-login redirect back to the current page, with an optional invite.
|
|
30
|
+
*
|
|
31
|
+
* "The current page" includes its QUERY STRING, through the same `postSignInUrl` the credential
|
|
32
|
+
* forms reload to, so the round-trip through an identity provider lands where the person started
|
|
33
|
+
* rather than one level up. A flow that carries its whole subject there (`/mcp-authorize?request=`)
|
|
34
|
+
* otherwise comes back from the IdP to a page that no longer knows what it was asked, and this is
|
|
35
|
+
* the path an SSO deployment takes for EVERY sign-in, not an unusual one.
|
|
36
|
+
*
|
|
37
|
+
* The `invite` is dropped from the returned-to URL by that helper and named as its own parameter
|
|
38
|
+
* here, which is the same token travelling as itself rather than twice.
|
|
39
|
+
*/
|
|
28
40
|
function redirectTarget(invite?: string): string {
|
|
29
|
-
const here = window.location.origin + window.location
|
|
41
|
+
const here = window.location.origin + postSignInUrl(window.location)
|
|
30
42
|
const params = new URLSearchParams({ redirect: here })
|
|
31
43
|
if (invite) params.set('invite', invite)
|
|
32
44
|
return params.toString()
|
|
@@ -207,10 +207,17 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Patch the user-editable fields of a block (title, features, threshold…).
|
|
212
|
+
*
|
|
213
|
+
* Returns whether the patch was PERSISTED. Both failure modes are already reported here (an
|
|
214
|
+
* unknown block is a no-op, a rejected write rolls back and toasts), so an inspector control
|
|
215
|
+
* firing and forgetting stays correct. A caller that goes on to ASSERT what the patch achieved
|
|
216
|
+
* must read it, or it announces links the rollback has just undone.
|
|
217
|
+
*/
|
|
218
|
+
async function updateBlock(id: string, patch: UpdateBlockInput): Promise<boolean> {
|
|
212
219
|
const b = getBlock(id)
|
|
213
|
-
if (!b) return
|
|
220
|
+
if (!b) return false
|
|
214
221
|
// Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
|
|
215
222
|
// (a patch may set several at once) rather than leaving a stale optimistic value stuck on
|
|
216
223
|
// screen with no feedback — the same rollback contract the other mutations here follow.
|
|
@@ -224,6 +231,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
224
231
|
Object.assign(b, patch) // optimistic
|
|
225
232
|
try {
|
|
226
233
|
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
234
|
+
return true
|
|
227
235
|
} catch (e) {
|
|
228
236
|
// Re-resolve the block: a live event may have replaced its object reference (`upsert`
|
|
229
237
|
// swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
|
|
@@ -242,6 +250,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
242
250
|
icon: 'i-lucide-triangle-alert',
|
|
243
251
|
color: 'error',
|
|
244
252
|
})
|
|
253
|
+
return false
|
|
245
254
|
}
|
|
246
255
|
}
|
|
247
256
|
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -256,12 +256,22 @@ describe('board store read getters', () => {
|
|
|
256
256
|
s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
|
|
257
257
|
// With no active workspace, `requireId()` throws inside updateBlock's try — the same catch
|
|
258
258
|
// that a rejected API write hits — so this exercises the optimistic-rollback + toast path.
|
|
259
|
-
|
|
259
|
+
// The outcome is REPORTED to the caller, not only toasted: a caller that goes on to announce
|
|
260
|
+
// what the patch achieved (the monorepo import's frontend wiring) has to see the rollback.
|
|
261
|
+
await expect(s.updateBlock('f1', { title: 'Edited', description: 'changed' })).resolves.toBe(
|
|
262
|
+
false,
|
|
263
|
+
)
|
|
260
264
|
expect(s.getBlock('f1')?.title).toBe('Original')
|
|
261
265
|
expect(s.getBlock('f1')?.description).toBe('orig')
|
|
262
266
|
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
|
|
263
267
|
})
|
|
264
268
|
|
|
269
|
+
it('updateBlock reports a no-op for a block that is not on the board', async () => {
|
|
270
|
+
// Nothing is patched and nothing is toasted, so the return value is the ONLY signal that the
|
|
271
|
+
// write did not happen.
|
|
272
|
+
await expect(store.updateBlock('missing', { title: 'Edited' })).resolves.toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
265
275
|
it('hydrate replaces and upsert inserts/updates cached blocks', () => {
|
|
266
276
|
store.hydrate([frame('f1')])
|
|
267
277
|
store.upsert(task('t1', 'f1', { title: 'first' }))
|
|
@@ -305,11 +315,21 @@ describe('board store optimistic rollback', () => {
|
|
|
305
315
|
}))
|
|
306
316
|
const store = useBoardStore()
|
|
307
317
|
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig', description: 'keep' })])
|
|
308
|
-
await store.updateBlock('t1', { title: 'renamed' })
|
|
318
|
+
await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(false)
|
|
309
319
|
expect(store.getBlock('t1')?.title).toBe('orig')
|
|
310
320
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
311
321
|
})
|
|
312
322
|
|
|
323
|
+
it('updateBlock reports the patch persisted when the API accepts it', async () => {
|
|
324
|
+
vi.stubGlobal('useApi', () => ({
|
|
325
|
+
updateBlock: async () => task('t1', 'f1', { title: 'renamed' }),
|
|
326
|
+
}))
|
|
327
|
+
const store = useBoardStore()
|
|
328
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig' })])
|
|
329
|
+
await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(true)
|
|
330
|
+
expect(store.getBlock('t1')?.title).toBe('renamed')
|
|
331
|
+
})
|
|
332
|
+
|
|
313
333
|
it('previewResize translates the children when the drag moves the content origin', () => {
|
|
314
334
|
// A child's position is relative to its container's content origin, so growing the frame
|
|
315
335
|
// 40px west (origin -40) has to move every direct child +40 or the whole content slides with
|