@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.
Files changed (34) hide show
  1. package/README.md +34 -0
  2. package/app/components/auth/LoginScreen.vue +4 -3
  3. package/app/components/board/BoardCanvas.vue +9 -1
  4. package/app/components/board/TaskDependencyEdges.vue +35 -26
  5. package/app/components/github/AddServiceFromRepoModal.vue +132 -29
  6. package/app/components/settings/McpAuthorizeScreen.vue +262 -0
  7. package/app/composables/api/mcpAuthorization.ts +30 -0
  8. package/app/composables/useApi.ts +2 -0
  9. package/app/composables/useBoardActivity.ts +111 -0
  10. package/app/composables/useSettlingRaf.ts +32 -0
  11. package/app/composables/useTaskExpansion.ts +29 -11
  12. package/app/pages/mcp-authorize.vue +7 -0
  13. package/app/stores/auth/session.ts +14 -2
  14. package/app/stores/board/placement.ts +12 -3
  15. package/app/stores/board.spec.ts +22 -2
  16. package/app/utils/edgeSegments.spec.ts +49 -0
  17. package/app/utils/edgeSegments.ts +49 -0
  18. package/app/utils/monorepoImport.spec.ts +120 -0
  19. package/app/utils/monorepoImport.ts +154 -0
  20. package/app/utils/postSignIn.spec.ts +29 -0
  21. package/app/utils/postSignIn.ts +29 -0
  22. package/app/utils/settlingLoop.spec.ts +236 -0
  23. package/app/utils/settlingLoop.ts +101 -0
  24. package/i18n/locales/de.json +45 -1
  25. package/i18n/locales/en.json +45 -1
  26. package/i18n/locales/es.json +45 -1
  27. package/i18n/locales/fr.json +45 -1
  28. package/i18n/locales/he.json +45 -1
  29. package/i18n/locales/it.json +45 -1
  30. package/i18n/locales/ja.json +45 -1
  31. package/i18n/locales/pl.json +45 -1
  32. package/i18n/locales/tr.json +45 -1
  33. package/i18n/locales/uk.json +45 -1
  34. 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
- function recompute() {
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
- if (store.hoveredId !== hovered) store.setHovered(hovered)
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) store.setAllowed(new Set())
93
- return
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)) store.setAllowed(next)
161
+ if (!sameSet(next, store.allowed)) {
162
+ store.setAllowed(next)
163
+ changed = true
164
+ }
165
+ return changed
149
166
  }
150
167
 
151
- const { pause, resume } = useRafFn(recompute, { immediate: false })
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)
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import McpAuthorizeScreen from '~/components/settings/McpAuthorizeScreen.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <McpAuthorizeScreen />
7
+ </template>
@@ -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
- /** Build a post-login redirect back to the current page, with an optional invite. */
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.pathname
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
- /** Patch the user-editable fields of a block (title, features, threshold…). */
211
- async function updateBlock(id: string, patch: UpdateBlockInput) {
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
 
@@ -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
- await s.updateBlock('f1', { title: 'Edited', description: 'changed' })
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