@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.
- package/app/components/auth/LoginScreen.vue +8 -2
- package/app/components/board/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/BlockNode.vue +24 -0
- package/app/components/board/nodes/TaskCard.vue +1 -1
- package/app/components/bootstrap/BootstrapModal.vue +5 -3
- package/app/components/context/ContextAttachmentFields.vue +102 -0
- package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
- package/app/components/context/pastedLinkOffer.logic.ts +50 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
- package/app/components/documents/SpawnPreviewModal.vue +92 -16
- package/app/components/documents/StartFromDesignModal.vue +237 -0
- package/app/components/github/GitHubPanel.vue +49 -10
- package/app/components/layout/AccountDeploymentSettings.vue +122 -0
- package/app/components/panels/InspectorPanel.vue +14 -9
- package/app/components/vcs/GitLabConnect.vue +8 -2
- package/app/composables/api/documents.ts +17 -6
- package/app/composables/useDocumentSourceConnect.ts +88 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +10 -0
- package/app/modular/nav-gates.ts +4 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -0
- package/app/modular/tutorial-tours.ts +77 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/documents.spec.ts +60 -0
- package/app/stores/documents.ts +52 -24
- package/app/stores/github/vcsConnect.ts +19 -0
- package/app/stores/github.spec.ts +116 -6
- package/app/stores/github.ts +23 -6
- package/app/stores/ui/modals.ts +15 -0
- package/app/utils/vcs.spec.ts +105 -14
- package/app/utils/vcs.ts +113 -29
- package/i18n/locales/de.json +118 -25
- package/i18n/locales/en.json +118 -25
- package/i18n/locales/es.json +118 -25
- package/i18n/locales/fr.json +118 -25
- package/i18n/locales/he.json +118 -25
- package/i18n/locales/it.json +118 -25
- package/i18n/locales/ja.json +118 -25
- package/i18n/locales/pl.json +118 -25
- package/i18n/locales/tr.json +118 -25
- package/i18n/locales/uk.json +118 -25
- 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(
|
|
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(
|
|
269
|
+
toast.add({ title: t(pullTerms.value.opened), icon: 'i-lucide-check', color: 'success' })
|
|
237
270
|
} catch (e) {
|
|
238
|
-
notifyError(t(
|
|
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(
|
|
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
|
-
{{
|
|
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
|
-
{{
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
-
|
|
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="
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|