@cat-factory/app 0.248.0 → 0.250.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 (45) hide show
  1. package/app/components/auth/LoginScreen.vue +8 -2
  2. package/app/components/board/AddTaskModal.vue +1 -0
  3. package/app/components/board/nodes/BlockNode.vue +24 -0
  4. package/app/components/board/nodes/TaskCard.vue +1 -1
  5. package/app/components/bootstrap/BootstrapModal.vue +5 -3
  6. package/app/components/context/ContextAttachmentFields.vue +102 -0
  7. package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
  8. package/app/components/context/pastedLinkOffer.logic.ts +50 -0
  9. package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
  10. package/app/components/documents/SpawnPreviewModal.vue +92 -16
  11. package/app/components/documents/StartFromDesignModal.vue +237 -0
  12. package/app/components/github/GitHubPanel.vue +49 -10
  13. package/app/components/layout/AccountDeploymentSettings.vue +122 -0
  14. package/app/components/panels/InspectorPanel.vue +14 -9
  15. package/app/components/vcs/GitLabConnect.vue +8 -2
  16. package/app/composables/api/documents.ts +17 -6
  17. package/app/composables/useDocumentSourceConnect.ts +88 -0
  18. package/app/composables/usePipelineErrorToast.ts +2 -0
  19. package/app/modular/external-tools.spec.ts +1 -0
  20. package/app/modular/nav-contributions.spec.ts +2 -0
  21. package/app/modular/nav-contributions.ts +10 -0
  22. package/app/modular/nav-gates.ts +4 -0
  23. package/app/modular/registry.spec.ts +1 -0
  24. package/app/modular/tutorial-tours.spec.ts +5 -0
  25. package/app/modular/tutorial-tours.ts +77 -0
  26. package/app/pages/index.vue +4 -0
  27. package/app/stores/documents.spec.ts +60 -0
  28. package/app/stores/documents.ts +52 -24
  29. package/app/stores/github/vcsConnect.ts +19 -0
  30. package/app/stores/github.spec.ts +116 -6
  31. package/app/stores/github.ts +23 -6
  32. package/app/stores/ui/modals.ts +15 -0
  33. package/app/utils/vcs.spec.ts +105 -14
  34. package/app/utils/vcs.ts +113 -29
  35. package/i18n/locales/de.json +118 -25
  36. package/i18n/locales/en.json +118 -25
  37. package/i18n/locales/es.json +118 -25
  38. package/i18n/locales/fr.json +118 -25
  39. package/i18n/locales/he.json +118 -25
  40. package/i18n/locales/it.json +118 -25
  41. package/i18n/locales/ja.json +118 -25
  42. package/i18n/locales/pl.json +118 -25
  43. package/i18n/locales/tr.json +118 -25
  44. package/i18n/locales/uk.json +118 -25
  45. package/package.json +2 -2
@@ -0,0 +1,237 @@
1
+ <script setup lang="ts">
2
+ // Start a task from a design link: paste a Figma/Zeplin URL on a service frame, and the resolved
3
+ // reference is staged onto the add-task form as context. One affordance, three steps that used to
4
+ // be four separate surfaces (Integrations hub → import modal → board → add task → attach).
5
+ //
6
+ // Three rules this surface exists to hold, each of which the obvious version gets wrong.
7
+ //
8
+ // - It resolves the reference BEFORE anything is created, through the same
9
+ // `POST /document-sources/:source/resolve-ref` the attach picker uses, so a share link's title
10
+ // segment and tracking params are trimmed where the person who pasted them can still see it,
11
+ // and a link the source cannot read is a correction rather than a toast over a task that
12
+ // already exists without its design.
13
+ // - It only asks the CONNECTED DESIGN sources, and it asks them in claim-confidence order
14
+ // (host-pinned first), because a host-blind prose parser will happily claim a Figma URL whose
15
+ // file key carries a UUID-shaped run. `connectedDesignSources` is already in registry order
16
+ // and every design source is host-pinned, so the order is the store's.
17
+ // - A WIDENED reference is stated separately from a trimmed one. Figma's own Copy link emits a
18
+ // complex instance id for any component instance, which the parser cannot read, so it falls
19
+ // back to the whole file: "I attached this frame" and "I attached the entire design" otherwise
20
+ // render identically. For a designer that widening IS the defect, not a detail.
21
+ import { refRowFor, classifyRefFailure, type RefState } from './ContextDocumentPicker.logic'
22
+ import type { DocumentSourceKind, ResolvedDocumentRef } from '~/types/domain'
23
+
24
+ const { t } = useI18n()
25
+ const ui = useUiStore()
26
+ const documents = useDocumentsStore()
27
+
28
+ const open = computed({
29
+ get: () => ui.startFromDesign !== null,
30
+ set: (v: boolean) => {
31
+ if (!v) ui.closeStartFromDesign()
32
+ },
33
+ })
34
+
35
+ const pasted = ref('')
36
+ const state = ref<RefState>({ status: 'none' })
37
+ /** The source that claimed the paste, held beside the verdict so the staged item names it. */
38
+ const claimant = ref<DocumentSourceKind | null>(null)
39
+ /**
40
+ * The exact text {@link state} is the verdict FOR, so the same text is never re-resolved.
41
+ *
42
+ * This is what keeps Continue clickable. The input resolves on blur, and clicking Continue blurs
43
+ * it, so a person who resolved with Enter and then reached for the button re-entered `resolve()`
44
+ * on mousedown: the state fell back to `checking`, `row` went null, and Vue disabled the button
45
+ * before mouseup, so the click was never dispatched. The button came back enabled a moment later
46
+ * having done nothing, which reads as a dead button rather than as a race. (The `start-from-design`
47
+ * tour hit the same edge, its `advanceOn: 'target-click'` step waiting on a click that never fired.)
48
+ *
49
+ * Guarding on the TEXT rather than suppressing the blur is what makes it a fix instead of a
50
+ * workaround: re-resolving input that already has a verdict spends one HTTP call per connected
51
+ * design source to arrive back where it started, whoever caused it.
52
+ */
53
+ const resolvedFor = ref<string | null>(null)
54
+
55
+ watch(open, (isOpen) => {
56
+ if (!isOpen) return
57
+ pasted.value = ''
58
+ state.value = { status: 'none' }
59
+ claimant.value = null
60
+ resolvedFor.value = null
61
+ })
62
+
63
+ const row = computed(() => refRowFor(state.value, pasted.value))
64
+ const sources = computed(() => documents.connectedDesignSources)
65
+
66
+ /**
67
+ * The source the staged reference will be attached to.
68
+ *
69
+ * An UNCHECKED paste (the pre-flight itself failed: offline, a 502, a proxy's error page) is not
70
+ * a refusal, so it stays stageable with the import as the backstop it always was — but only when
71
+ * ONE design source is connected, because that is the only case where nothing has to be guessed.
72
+ * With two, the source is exactly what the resolve was going to tell us, and picking the
73
+ * first-registered one would attach a Zeplin screen to Figma's key space. Then the honest answer
74
+ * is to say the check could not be made and let the person retry.
75
+ */
76
+ const target = computed<DocumentSourceKind | null>(() => {
77
+ if (claimant.value) return claimant.value
78
+ if (state.value.status !== 'unchecked') return null
79
+ return sources.value.length === 1 ? (sources.value[0] ?? null) : null
80
+ })
81
+
82
+ /**
83
+ * Ask each connected design source in turn and keep the FIRST that claims the paste.
84
+ *
85
+ * Sequential rather than parallel: the sources are ordered by how much a claim over a URL is
86
+ * worth, and running them together would make the winner whichever answered first. A refusal from
87
+ * one source is not a refusal of the paste, so only the LAST one is surfaced when nobody claims
88
+ * it — that is the state where the person genuinely has to change something.
89
+ */
90
+ async function resolve() {
91
+ const text = pasted.value.trim()
92
+ // Already judged, so there is nothing to learn and a verdict to lose (see `resolvedFor`).
93
+ if (text === resolvedFor.value) return
94
+ claimant.value = null
95
+ if (!text) {
96
+ state.value = { status: 'none' }
97
+ resolvedFor.value = null
98
+ return
99
+ }
100
+ state.value = { status: 'checking' }
101
+ let last: RefState = { status: 'rejected', reason: 'document_ref_unrecognized' }
102
+ for (const source of sources.value) {
103
+ try {
104
+ const ref: ResolvedDocumentRef = await documents.resolveRef(source, text)
105
+ claimant.value = source
106
+ state.value = { status: 'ok', ref }
107
+ resolvedFor.value = text
108
+ return
109
+ } catch (e) {
110
+ last = classifyRefFailure(e)
111
+ // An outage leaves the paste UNJUDGED rather than refused, and asking the next source
112
+ // would turn one source being down into a different source's verdict.
113
+ if (last.status === 'unchecked') break
114
+ }
115
+ }
116
+ state.value = last
117
+ // A refusal and an outage are verdicts too: re-running on the same text would reach the same
118
+ // one, and an `unchecked` paste is stageable, so it must survive the blur Continue causes.
119
+ resolvedFor.value = text
120
+ }
121
+
122
+ /**
123
+ * Hand the resolved reference to the add-task form as a staged attachment.
124
+ *
125
+ * `needsImport` is true because nothing has been fetched yet: the form's own pre-create resolve
126
+ * (`useContextLinking().resolvePending`) performs the import, which is where an unreachable page
127
+ * becomes a correction the author can still make. Importing here as well would spend the fetch
128
+ * twice and move the failure back before the form exists.
129
+ */
130
+ function stage() {
131
+ const frame = ui.startFromDesign
132
+ const resolved = row.value
133
+ const source = target.value
134
+ if (!frame || !resolved || !source) return
135
+ const descriptor = documents.descriptorFor(source)
136
+ ui.closeStartFromDesign()
137
+ ui.openAddTask(frame.frameId, {
138
+ context: [
139
+ {
140
+ kind: 'document',
141
+ source,
142
+ externalId: resolved.externalId,
143
+ title: resolved.label,
144
+ subtitle: descriptor?.label,
145
+ icon: descriptor?.icon,
146
+ needsImport: true,
147
+ },
148
+ ],
149
+ })
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <UModal v-model:open="open" :title="t('documents.startFromDesign.title')">
155
+ <template #body>
156
+ <div class="space-y-4">
157
+ <p class="text-sm text-slate-400">{{ t('documents.startFromDesign.intro') }}</p>
158
+
159
+ <!-- No connected design source: the flow cannot run, and saying which step is missing
160
+ beats an input that refuses every paste. The connect route is withheld from a member
161
+ for the same reason the picker's add tier is: connecting stores a credential. -->
162
+ <p v-if="sources.length === 0" class="text-sm text-amber-300">
163
+ {{ t('documents.startFromDesign.noSource') }}
164
+ </p>
165
+
166
+ <template v-else>
167
+ <UFormField :label="t('documents.startFromDesign.linkLabel')">
168
+ <UInput
169
+ v-model="pasted"
170
+ :placeholder="t('documents.startFromDesign.linkPlaceholder')"
171
+ class="w-full"
172
+ data-testid="start-from-design-link"
173
+ @blur="resolve"
174
+ @keyup.enter="resolve"
175
+ />
176
+ </UFormField>
177
+
178
+ <div
179
+ v-if="state.status === 'checking'"
180
+ class="flex items-center gap-2 text-sm text-slate-400"
181
+ >
182
+ <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
183
+ {{ t('documents.startFromDesign.checking') }}
184
+ </div>
185
+
186
+ <div
187
+ v-else-if="row"
188
+ class="space-y-1 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
189
+ data-testid="start-from-design-resolved"
190
+ >
191
+ <div class="flex items-center gap-2 text-sm text-white">
192
+ <UIcon name="i-lucide-frame" class="h-4 w-4 text-indigo-400" />
193
+ <span class="truncate">{{ row.label }}</span>
194
+ </div>
195
+ <p v-if="row.trimmed" class="text-[11px] text-slate-400">
196
+ {{ t('documents.startFromDesign.trimmed') }}
197
+ </p>
198
+ <!-- Its own line, in amber: a trim resolves the same page, a drop widens ONE frame to
199
+ the whole design file, and the second is what a designer needs to see. -->
200
+ <p v-if="row.droppedScope" class="text-[11px] text-amber-300">
201
+ {{ t('documents.startFromDesign.widened', { scope: row.droppedScope }) }}
202
+ </p>
203
+ <p v-if="row.unchecked && target" class="text-[11px] text-slate-400">
204
+ {{ t('documents.startFromDesign.unchecked') }}
205
+ </p>
206
+ <p v-else-if="row.unchecked" class="text-[11px] text-amber-300">
207
+ {{ t('documents.startFromDesign.uncheckedAmbiguous') }}
208
+ </p>
209
+ </div>
210
+
211
+ <p
212
+ v-else-if="state.status === 'rejected'"
213
+ class="text-sm text-amber-300"
214
+ data-testid="start-from-design-rejected"
215
+ >
216
+ {{ t('documents.startFromDesign.rejected') }}
217
+ </p>
218
+ </template>
219
+
220
+ <div class="flex justify-end gap-2 pt-1">
221
+ <UButton color="neutral" variant="ghost" @click="ui.closeStartFromDesign()">
222
+ {{ t('common.cancel') }}
223
+ </UButton>
224
+ <UButton
225
+ color="primary"
226
+ icon="i-lucide-arrow-right"
227
+ :disabled="!row || !target"
228
+ data-testid="start-from-design-continue"
229
+ @click="stage"
230
+ >
231
+ {{ t('documents.startFromDesign.continue') }}
232
+ </UButton>
233
+ </div>
234
+ </div>
235
+ </template>
236
+ </UModal>
237
+ </template>
@@ -51,6 +51,39 @@ const CONNECTION_META: Record<VcsProvider, () => string> = {
51
51
  }
52
52
  const connectionMeta = computed(() => CONNECTION_META[github.provider]())
53
53
 
54
+ // GitHub opens PULL requests, GitLab opens MERGE requests, and the panel renders both providers'
55
+ // rows through one component. The vocabulary is therefore a per-provider set of STATIC catalog
56
+ // keys (an exhaustive Record, so a new provider fails the typecheck rather than shipping GitHub's
57
+ // nouns), resolved off the connected provider — the panel only ever lists one connection's repos.
58
+ const PULL_TERMS = {
59
+ github: {
60
+ tab: 'vcs.panel.pulls.github.tab',
61
+ open: 'vcs.panel.pulls.github.open',
62
+ openSubmit: 'vcs.panel.pulls.github.openSubmit',
63
+ merge: 'vcs.panel.pulls.github.merge',
64
+ none: 'vcs.panel.pulls.github.none',
65
+ opened: 'vcs.panel.pulls.github.opened',
66
+ mergedToast: 'vcs.panel.pulls.github.mergedToast',
67
+ openFailed: 'vcs.panel.pulls.github.openFailed',
68
+ },
69
+ gitlab: {
70
+ tab: 'vcs.panel.pulls.gitlab.tab',
71
+ open: 'vcs.panel.pulls.gitlab.open',
72
+ openSubmit: 'vcs.panel.pulls.gitlab.openSubmit',
73
+ merge: 'vcs.panel.pulls.gitlab.merge',
74
+ none: 'vcs.panel.pulls.gitlab.none',
75
+ opened: 'vcs.panel.pulls.gitlab.opened',
76
+ mergedToast: 'vcs.panel.pulls.gitlab.mergedToast',
77
+ openFailed: 'vcs.panel.pulls.gitlab.openFailed',
78
+ },
79
+ } as const satisfies Record<VcsProvider, Record<string, string>>
80
+ const pullTerms = computed(() => PULL_TERMS[github.provider])
81
+
82
+ // What the repo picker can offer, and why a repo might be missing from it, differ by how the
83
+ // workspace authenticates rather than by provider: an App installation is shared across the
84
+ // account and has its own access list, a pasted token reaches exactly what its owner does.
85
+ const isAppConnection = computed(() => github.connection?.method === 'app')
86
+
54
87
  // On open: refresh projections when connected. The not-connected state renders
55
88
  // <GitHubConnect>, which discovers and links installations on its own.
56
89
  watch(
@@ -111,7 +144,7 @@ type Tab = 'repos' | 'pulls' | 'issues'
111
144
  const tab = ref<Tab>('repos')
112
145
  const tabs = computed<{ id: Tab; label: string; icon: string }[]>(() => [
113
146
  { id: 'repos', label: t('github.panel.tabs.repos'), icon: 'i-lucide-folder-git-2' },
114
- { id: 'pulls', label: t('github.panel.tabs.pulls'), icon: 'i-lucide-git-pull-request' },
147
+ { id: 'pulls', label: t(pullTerms.value.tab), icon: 'i-lucide-git-pull-request' },
115
148
  { id: 'issues', label: t('github.panel.tabs.issues'), icon: 'i-lucide-circle-dot' },
116
149
  ])
117
150
 
@@ -233,9 +266,9 @@ async function openPr() {
233
266
  })
234
267
  showPrForm.value = false
235
268
  prForm.value = { repoGithubId: null, title: '', head: '', base: '' }
236
- toast.add({ title: t('github.panel.toast.prOpened'), icon: 'i-lucide-check', color: 'success' })
269
+ toast.add({ title: t(pullTerms.value.opened), icon: 'i-lucide-check', color: 'success' })
237
270
  } catch (e) {
238
- notifyError(t('github.panel.errors.openPr'), e)
271
+ notifyError(t(pullTerms.value.openFailed), e)
239
272
  } finally {
240
273
  openingPr.value = false
241
274
  }
@@ -258,7 +291,7 @@ async function merge(pr: GitHubPullRequest) {
258
291
  try {
259
292
  await github.mergePullRequest(pr.repoGithubId, pr.number, { method: 'squash' })
260
293
  toast.add({
261
- title: t('github.panel.toast.prMerged', { number: pr.number }),
294
+ title: t(pullTerms.value.mergedToast, { number: pr.number }),
262
295
  icon: 'i-lucide-git-merge',
263
296
  color: 'success',
264
297
  })
@@ -378,7 +411,9 @@ async function merge(pr: GitHubPullRequest) {
378
411
  class="space-y-2 rounded-md border border-slate-700 bg-slate-900/80 p-3"
379
412
  >
380
413
  <p class="text-[12px] text-slate-400">
381
- {{ t('github.panel.manageHint') }}
414
+ {{
415
+ isAppConnection ? t('vcs.panel.manageHintApp') : t('vcs.panel.manageHintToken')
416
+ }}
382
417
  </p>
383
418
  <div
384
419
  v-if="github.loadingAvailable"
@@ -388,7 +423,11 @@ async function merge(pr: GitHubPullRequest) {
388
423
  {{ t('github.panel.loadingRepos') }}
389
424
  </div>
390
425
  <p v-else-if="!github.availableRepos.length" class="py-2 text-sm text-slate-400">
391
- {{ t('github.panel.noAvailableRepos') }}
426
+ {{
427
+ isAppConnection
428
+ ? t('vcs.panel.noAvailableReposApp')
429
+ : t('vcs.panel.noAvailableReposToken')
430
+ }}
392
431
  </p>
393
432
  <div v-else class="max-h-64 space-y-1 overflow-y-auto">
394
433
  <button
@@ -554,7 +593,7 @@ async function merge(pr: GitHubPullRequest) {
554
593
  }
555
594
  "
556
595
  >
557
- {{ t('github.panel.openPr') }}
596
+ {{ t(pullTerms.open) }}
558
597
  </UButton>
559
598
  </div>
560
599
 
@@ -599,13 +638,13 @@ async function merge(pr: GitHubPullRequest) {
599
638
  :disabled="!canOpenPr"
600
639
  @click="openPr"
601
640
  >
602
- {{ t('github.panel.openPullRequest') }}
641
+ {{ t(pullTerms.openSubmit) }}
603
642
  </UButton>
604
643
  </div>
605
644
  </div>
606
645
 
607
646
  <p v-if="!github.pulls.length" class="py-4 text-sm text-slate-400">
608
- {{ t('github.panel.noPulls') }}
647
+ {{ t(pullTerms.none) }}
609
648
  </p>
610
649
  <div
611
650
  v-for="pr in github.pulls"
@@ -634,7 +673,7 @@ async function merge(pr: GitHubPullRequest) {
634
673
  color="neutral"
635
674
  variant="ghost"
636
675
  icon="i-lucide-git-merge"
637
- :aria-label="t('github.panel.mergePr')"
676
+ :aria-label="t(pullTerms.merge)"
638
677
  :loading="merging === pr.number"
639
678
  @click="merge(pr)"
640
679
  />
@@ -39,9 +39,14 @@ watch(
39
39
 
40
40
  const slack = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
41
41
  const linear = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
42
+ // The deployment's registered Figma app, which is what turns "Connect with Figma" on for every
43
+ // board in the account. Without it the Figma document source still connects, by personal access
44
+ // token — which is the step this exists to spare a designer.
45
+ const figma = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
42
46
  const web = reactive({ braveApiKey: '', searxngUrl: '', searxngApiKey: '' })
43
47
  const savingSlack = ref(false)
44
48
  const savingLinear = ref(false)
49
+ const savingFigma = ref(false)
45
50
  const savingWeb = ref(false)
46
51
 
47
52
  const summary = computed(() => store.view?.summary ?? null)
@@ -289,6 +294,62 @@ async function clearLinear() {
289
294
  }
290
295
  }
291
296
 
297
+ async function saveFigma() {
298
+ if (!figma.clientId.trim() || !figma.clientSecret.trim() || !figma.redirectUrl.trim()) {
299
+ toast.add({ title: t('layout.accountDeployment.figma.validation'), color: 'error' })
300
+ return
301
+ }
302
+ savingFigma.value = true
303
+ try {
304
+ await store.save(props.accountId, {
305
+ secrets: {
306
+ figmaOAuth: {
307
+ clientId: figma.clientId.trim(),
308
+ clientSecret: figma.clientSecret.trim(),
309
+ redirectUrl: figma.redirectUrl.trim(),
310
+ },
311
+ },
312
+ })
313
+ figma.clientId = ''
314
+ figma.clientSecret = ''
315
+ figma.redirectUrl = ''
316
+ toast.add({
317
+ title: t('layout.accountDeployment.figma.saved'),
318
+ icon: 'i-lucide-check',
319
+ color: 'success',
320
+ })
321
+ } catch (e) {
322
+ toast.add({
323
+ title: t('layout.accountDeployment.figma.saveFailed'),
324
+ description: e instanceof Error ? e.message : String(e),
325
+ color: 'error',
326
+ })
327
+ } finally {
328
+ savingFigma.value = false
329
+ }
330
+ }
331
+
332
+ async function clearFigma() {
333
+ if (!(await confirmAction('clear', 'Figma'))) return
334
+ savingFigma.value = true
335
+ try {
336
+ await store.save(props.accountId, { secrets: { figmaOAuth: null } })
337
+ toast.add({
338
+ title: t('layout.accountDeployment.figma.cleared'),
339
+ icon: 'i-lucide-check',
340
+ color: 'success',
341
+ })
342
+ } catch (e) {
343
+ toast.add({
344
+ title: t('layout.accountDeployment.figma.clearFailed'),
345
+ description: e instanceof Error ? e.message : String(e),
346
+ color: 'error',
347
+ })
348
+ } finally {
349
+ savingFigma.value = false
350
+ }
351
+ }
352
+
292
353
  async function saveWeb() {
293
354
  const brave = web.braveApiKey.trim()
294
355
  const searxng = web.searxngUrl.trim()
@@ -479,6 +540,67 @@ async function clearWeb() {
479
540
  </div>
480
541
  </section>
481
542
 
543
+ <!-- Figma app OAuth (the document source's designer-doable connect) -->
544
+ <section class="space-y-2 border-t border-slate-800 pt-6">
545
+ <div class="flex items-center gap-2">
546
+ <h4 class="text-sm font-semibold text-slate-200">
547
+ {{ t('layout.accountDeployment.figma.title') }}
548
+ </h4>
549
+ <UBadge
550
+ :color="summary?.figmaOAuthConfigured ? 'success' : 'neutral'"
551
+ variant="subtle"
552
+ size="xs"
553
+ >
554
+ {{
555
+ summary?.figmaOAuthConfigured
556
+ ? t('layout.accountDeployment.configured')
557
+ : t('layout.accountDeployment.notSet')
558
+ }}
559
+ </UBadge>
560
+ </div>
561
+ <p class="text-[11px] text-slate-400">
562
+ {{ t('layout.accountDeployment.figma.description') }}
563
+ </p>
564
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
565
+ <UInput
566
+ v-model="figma.clientId"
567
+ :placeholder="t('layout.accountDeployment.figma.clientId')"
568
+ size="sm"
569
+ />
570
+ <SecretInput
571
+ v-model="figma.clientSecret"
572
+ :placeholder="t('layout.accountDeployment.figma.clientSecret')"
573
+ size="sm"
574
+ />
575
+ <UInput
576
+ v-model="figma.redirectUrl"
577
+ :placeholder="t('layout.accountDeployment.figma.redirectUrl')"
578
+ size="sm"
579
+ />
580
+ </div>
581
+ <div class="flex gap-2">
582
+ <UButton
583
+ color="primary"
584
+ size="xs"
585
+ icon="i-lucide-save"
586
+ :loading="savingFigma"
587
+ @click="saveFigma"
588
+ >
589
+ {{ t('common.save') }}
590
+ </UButton>
591
+ <UButton
592
+ v-if="summary?.figmaOAuthConfigured"
593
+ color="neutral"
594
+ variant="ghost"
595
+ size="xs"
596
+ :loading="savingFigma"
597
+ @click="clearFigma"
598
+ >
599
+ {{ t('layout.accountDeployment.clear') }}
600
+ </UButton>
601
+ </div>
602
+ </section>
603
+
482
604
  <!-- Web search keys -->
483
605
  <section class="space-y-2 border-t border-slate-800 pt-6">
484
606
  <div class="flex items-center gap-2">
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
7
7
  import IconButton from '~/components/common/IconButton.vue'
8
8
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
9
9
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
10
+ import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
10
11
 
11
12
  const board = useBoardStore()
12
13
  const pipelines = usePipelinesStore()
@@ -163,19 +164,23 @@ const serviceRepo = computed(() =>
163
164
  const serviceRepoUrl = computed(() =>
164
165
  serviceRepo.value ? github.repoUrl(serviceRepo.value.githubId) : null,
165
166
  )
166
-
167
- // A task's work branch on GitHub, once the agent has pushed one (a PR branch is
168
- // recorded on the block). Repo linkage lives on the owning service frame, not the
169
- // task, so resolve the repo by walking up to the frame; fall back to deriving the
170
- // repo base from the PR url when the projection hasn't loaded. Null until a branch
171
- // exists, so the link only appears after one is created.
167
+ // The repo link wears its own provider's mark, off the projection row rather than the
168
+ // workspace connection, so a row is never labelled with a brand it does not belong to.
169
+ const serviceRepoIcon = computed(() => VCS_PROVIDER_ICONS[serviceRepo.value?.provider ?? 'github'])
170
+
171
+ // A task's work branch on the connected host, once the agent has pushed one (a PR branch is
172
+ // recorded on the block). Repo linkage lives on the owning service frame, not the task, so
173
+ // resolve the repo by walking up to the frame, and let the store build the branch path for the
174
+ // repo's own provider (GitLab addresses a tree under `/-/`). Null until a branch exists, so the
175
+ // link only appears after one is created, and null while the projection has not loaded: the
176
+ // former fallback sliced `/pull/<n>` off the PR url, which silently yields nothing on a GitLab
177
+ // merge-request url and would need a second provider guess to fix.
172
178
  const taskBranchUrl = computed(() => {
173
179
  const pr = isTask.value ? block.value?.pullRequest : undefined
174
180
  if (!pr?.branch || !block.value) return null
175
181
  const frame = board.serviceOf(block.value)
176
182
  const repo = frame ? github.repoForBlock(frame.id) : undefined
177
- const base = repo ? github.repoUrl(repo.githubId) : pr.url.replace(/\/pull\/\d+$/, '')
178
- return base ? `${base}/tree/${pr.branch}` : null
183
+ return repo ? github.branchUrl(repo.githubId, pr.branch) : null
179
184
  })
180
185
 
181
186
  // The run MODE, shared with the focus view's Run picker so the two surfaces offer (and force)
@@ -470,7 +475,7 @@ const showOriginalDescription = ref(false)
470
475
  color="neutral"
471
476
  variant="soft"
472
477
  size="xs"
473
- icon="i-lucide-github"
478
+ :icon="serviceRepoIcon"
474
479
  trailing-icon="i-lucide-external-link"
475
480
  >
476
481
  {{ serviceRepo!.owner }}/{{ serviceRepo!.name }}
@@ -11,7 +11,7 @@
11
11
  // flattened into a generic toast.
12
12
  import SecretInput from '~/components/common/SecretInput.vue'
13
13
  import { apiErrorEnvelope } from '~/composables/api/errors'
14
- import { VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
14
+ import { vcsTokenCreateUrl } from '~/utils/vcs'
15
15
 
16
16
  const { t } = useI18n()
17
17
  const github = useGitHubStore()
@@ -21,7 +21,13 @@ const pat = ref('')
21
21
  const connecting = ref(false)
22
22
  const error = ref<string | null>(null)
23
23
 
24
- const tokenUrl = VCS_PROVIDER_TOKEN_URLS.gitlab
24
+ // The token page on the instance this deployment would connect, not on gitlab.com: a
25
+ // self-managed GitLab's tokens are minted on its own host. The advertised host is null when the
26
+ // deployment's API base does not name one, and only THIS link falls back to the public instance
27
+ // (a wrong settings page costs a click; see `~/utils/vcs`).
28
+ const tokenUrl = computed(() =>
29
+ vcsTokenCreateUrl('gitlab', github.connectOptions.find((o) => o.provider === 'gitlab')?.webUrl),
30
+ )
25
31
 
26
32
  async function connect() {
27
33
  const token = pat.value.trim()
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  connectDocumentSourceContract,
3
3
  disconnectDocumentSourceContract,
4
+ documentSourceOAuthUrlContract,
4
5
  importDocumentContract,
5
6
  linkDocumentContract,
6
7
  linkDocumentForKindContract,
@@ -41,6 +42,14 @@ export function documentsApi({ send, ws }: ApiContext) {
41
42
  body: { credentials },
42
43
  }),
43
44
 
45
+ // The vendor authorization URL for a source's OAuth connect. Admin-tier: what it hands back
46
+ // is the first half of a credential write, completed by the public callback.
47
+ documentSourceOAuthUrl: (workspaceId: string, source: DocumentSourceKind) =>
48
+ send(documentSourceOAuthUrlContract, {
49
+ pathPrefix: ws(workspaceId),
50
+ pathParams: { source },
51
+ }),
52
+
44
53
  disconnectDocumentSource: (workspaceId: string, source: DocumentSourceKind) =>
45
54
  send(disconnectDocumentSourceContract, {
46
55
  pathPrefix: ws(workspaceId),
@@ -77,12 +86,14 @@ export function documentsApi({ send, ws }: ApiContext) {
77
86
  body: { query },
78
87
  }),
79
88
 
80
- planDocument: (workspaceId: string, source: DocumentSourceKind, externalId: string) =>
81
- send(planDocumentContract, {
82
- pathPrefix: ws(workspaceId),
83
- pathParams: { source },
84
- body: { externalId },
85
- }),
89
+ // `frameId` makes the plan TARGET-AWARE: modules and tasks for a service that already exists
90
+ // rather than an architecture. The same frame is sent to the spawn, so the preview and the
91
+ // write agree about the target.
92
+ planDocument: (
93
+ workspaceId: string,
94
+ source: DocumentSourceKind,
95
+ body: { externalId: string; frameId?: string },
96
+ ) => send(planDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
86
97
 
87
98
  spawnDocument: (
88
99
  workspaceId: string,