@cat-factory/app 0.171.0 → 0.173.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 (35) hide show
  1. package/README.md +4 -0
  2. package/app/components/board/AddTaskModal.vue +10 -252
  3. package/app/components/board/CreateInitiativeModal.vue +27 -0
  4. package/app/components/board/nodes/InitiativeCard.vue +14 -0
  5. package/app/components/common/InterviewGateNotice.vue +39 -0
  6. package/app/components/context/ContextAttachmentFields.vue +289 -0
  7. package/app/components/docs/DocInterviewWindow.vue +77 -14
  8. package/app/components/fragments/FragmentLibraryManager.vue +91 -13
  9. package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
  10. package/app/components/github/RepoTreeBrowser.vue +65 -10
  11. package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
  12. package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
  13. package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
  14. package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
  15. package/app/components/settings/InfrastructureWindow.vue +8 -1
  16. package/app/composables/useContextLinking.ts +14 -2
  17. package/app/composables/useInitiativePlanning.ts +28 -6
  18. package/app/pages/index.vue +12 -1
  19. package/app/stores/ui/modals.ts +46 -0
  20. package/app/stores/workspaceSettings.ts +5 -0
  21. package/app/utils/defaultProvisioning.spec.ts +100 -0
  22. package/app/utils/defaultProvisioning.ts +99 -0
  23. package/app/utils/interviewGate.spec.ts +51 -0
  24. package/app/utils/interviewGate.ts +51 -0
  25. package/i18n/locales/de.json +70 -22
  26. package/i18n/locales/en.json +73 -22
  27. package/i18n/locales/es.json +70 -22
  28. package/i18n/locales/fr.json +70 -22
  29. package/i18n/locales/he.json +70 -22
  30. package/i18n/locales/it.json +70 -22
  31. package/i18n/locales/ja.json +70 -22
  32. package/i18n/locales/pl.json +70 -22
  33. package/i18n/locales/tr.json +70 -22
  34. package/i18n/locales/uk.json +70 -22
  35. package/package.json +2 -2
@@ -0,0 +1,289 @@
1
+ <script setup lang="ts">
2
+ // Stage external context (imported documents and tracker issues) for a board block that does not
3
+ // exist yet. Extracted verbatim from AddTaskModal so the initiative create flow gets the identical
4
+ // affordance rather than a second, drifting copy — the two are the same UI over the same
5
+ // `PendingContext` model, and only the guidance copy differs (hence the hint props).
6
+ //
7
+ // STAGED, not live: linking needs a block id, so picks are held here and committed by the host
8
+ // once the block exists (`useContextLinking().linkPending`). The inspector's own panels are the
9
+ // live counterpart, for a block that already exists.
10
+ //
11
+ // Both sections are ungated: when the relevant integration isn't connected the Attach button
12
+ // becomes a "Connect a source" action instead of vanishing, so the capability is discoverable.
13
+ // Connecting opens the source's connect modal OVER the host modal (both are root-mounted with
14
+ // independent open flags), so in-progress form data survives.
15
+ import type { PendingContext } from '~/composables/useContextLinking'
16
+ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
17
+ import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
18
+
19
+ const props = defineProps<{
20
+ /** The staged attachments; the host owns the array and commits it after creating the block. */
21
+ modelValue: PendingContext[]
22
+ /** Why attaching a document helps HERE — the one thing that differs between hosts. */
23
+ docsHint: string
24
+ /** Why attaching an issue helps HERE. */
25
+ issuesHint: string
26
+ /**
27
+ * The block the issue search is scoped to (the task's container / the initiative's service
28
+ * frame). REQUIRED by ContextIssuePicker: it is what confines a GitHub search to that
29
+ * service's linked repo, so there is no unscoped mode to fall back on.
30
+ */
31
+ scopeBlockId: string
32
+ }>()
33
+
34
+ const emit = defineEmits<{ 'update:modelValue': [PendingContext[]] }>()
35
+
36
+ const ui = useUiStore()
37
+ const documents = useDocumentsStore()
38
+ const tasks = useTasksStore()
39
+ const { t } = useI18n()
40
+
41
+ const docsConnected = computed(() => documents.available && documents.anyConnected)
42
+ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
43
+
44
+ // Sources the user could connect right now to unlock the picker, when none is connected yet: for
45
+ // documents, every configured source without a live connection (GitHub docs are already implicitly
46
+ // connected via the App, so they never appear here); for issues, every configured tracker not yet
47
+ // available. The connect modals are the same ones the Integrations hub opens.
48
+ const connectableDocSources = computed(() =>
49
+ documents.available ? documents.sources.filter((s) => !documents.isConnected(s.source)) : [],
50
+ )
51
+ const connectableIssueSources = computed(() =>
52
+ tasks.available ? tasks.sources.filter((s) => !s.available) : [],
53
+ )
54
+ const connectDocMenu = computed(() => [
55
+ connectableDocSources.value.map((s) => ({
56
+ label: s.label,
57
+ icon: s.icon,
58
+ onSelect: () => ui.openDocumentConnect(s.source),
59
+ })),
60
+ ])
61
+ const connectIssueMenu = computed(() => [
62
+ connectableIssueSources.value.map((s) => ({
63
+ label: s.label,
64
+ icon: s.icon,
65
+ onSelect: () => ui.openTaskConnect(s.source),
66
+ })),
67
+ ])
68
+
69
+ const pendingDocs = computed(() => props.modelValue.filter((c) => c.kind === 'document'))
70
+ const pendingIssues = computed(() => props.modelValue.filter((c) => c.kind === 'task'))
71
+
72
+ // Context is picked through an inline search picker rather than a dropdown that opens a second,
73
+ // page-level modal: stacked page-level modals don't interact, so such a menu appears to open
74
+ // something with nothing clickable. The "Attach" button toggles the relevant picker open.
75
+ const showDocPicker = ref(false)
76
+ const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
77
+ const showIssuePicker = ref(false)
78
+ const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
79
+
80
+ function addPending(item: PendingContext) {
81
+ if (props.modelValue.some((c) => contextKey(c) === contextKey(item))) return
82
+ emit('update:modelValue', [...props.modelValue, item])
83
+ }
84
+ function removePending(item: PendingContext) {
85
+ emit(
86
+ 'update:modelValue',
87
+ props.modelValue.filter((c) => contextKey(c) !== contextKey(item)),
88
+ )
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <div class="space-y-4">
94
+ <!-- Context documents (ungated; Attach disabled until a source is connected). -->
95
+ <div class="space-y-2">
96
+ <div class="flex items-center justify-between">
97
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
98
+ {{ t('contextAttachments.documents') }}
99
+ </span>
100
+ <UButton
101
+ v-if="docsConnected"
102
+ color="neutral"
103
+ variant="soft"
104
+ size="xs"
105
+ :icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
106
+ @click="
107
+ () => {
108
+ showDocPicker = !showDocPicker
109
+ }
110
+ "
111
+ >
112
+ {{ showDocPicker ? t('contextAttachments.done') : t('contextAttachments.attach') }}
113
+ </UButton>
114
+ <UDropdownMenu
115
+ v-else-if="connectableDocSources.length > 1"
116
+ :items="connectDocMenu"
117
+ :content="{ side: 'bottom', align: 'end' }"
118
+ >
119
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
120
+ {{ t('contextAttachments.connectSource') }}
121
+ </UButton>
122
+ </UDropdownMenu>
123
+ <UButton
124
+ v-else-if="connectableDocSources.length === 1"
125
+ color="neutral"
126
+ variant="soft"
127
+ size="xs"
128
+ icon="i-lucide-plug"
129
+ @click="ui.openDocumentConnect(connectableDocSources[0]!.source)"
130
+ >
131
+ {{
132
+ t('contextAttachments.connectSourceNamed', { source: connectableDocSources[0]!.label })
133
+ }}
134
+ </UButton>
135
+ <UButton
136
+ v-else
137
+ color="neutral"
138
+ variant="soft"
139
+ size="xs"
140
+ icon="i-lucide-plus"
141
+ disabled
142
+ :title="
143
+ documents.available
144
+ ? t('contextAttachments.attachDocDisabledConnect')
145
+ : t('contextAttachments.attachDocDisabledEnable')
146
+ "
147
+ >
148
+ {{ t('contextAttachments.attach') }}
149
+ </UButton>
150
+ </div>
151
+ <ContextDocumentPicker
152
+ v-if="showDocPicker && docsConnected"
153
+ :chosen-keys="chosenDocKeys"
154
+ @pick="addPending"
155
+ />
156
+ <div v-if="pendingDocs.length" class="space-y-1">
157
+ <div
158
+ v-for="item in pendingDocs"
159
+ :key="contextKey(item)"
160
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
161
+ >
162
+ <UIcon
163
+ :name="item.icon ?? 'i-lucide-file-text'"
164
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
165
+ />
166
+ <span class="truncate">{{ item.title }}</span>
167
+ <UBadge
168
+ v-if="item.needsImport"
169
+ color="neutral"
170
+ variant="soft"
171
+ size="xs"
172
+ class="ms-1 shrink-0"
173
+ >
174
+ {{ t('contextAttachments.importsOnAdd') }}
175
+ </UBadge>
176
+ <button
177
+ type="button"
178
+ class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
179
+ @click="removePending(item)"
180
+ >
181
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
182
+ </button>
183
+ </div>
184
+ </div>
185
+ <p v-else class="text-[11px] text-slate-500">
186
+ {{ docsHint }}
187
+ </p>
188
+ </div>
189
+
190
+ <!-- Context issues (ungated; Attach disabled until a tracker is connected). -->
191
+ <div class="space-y-2">
192
+ <div class="flex items-center justify-between">
193
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
194
+ {{ t('contextAttachments.issues') }}
195
+ </span>
196
+ <UButton
197
+ v-if="issuesConnected"
198
+ color="neutral"
199
+ variant="soft"
200
+ size="xs"
201
+ :icon="showIssuePicker ? 'i-lucide-x' : 'i-lucide-plus'"
202
+ @click="
203
+ () => {
204
+ showIssuePicker = !showIssuePicker
205
+ }
206
+ "
207
+ >
208
+ {{ showIssuePicker ? t('contextAttachments.done') : t('contextAttachments.attach') }}
209
+ </UButton>
210
+ <UDropdownMenu
211
+ v-else-if="connectableIssueSources.length > 1"
212
+ :items="connectIssueMenu"
213
+ :content="{ side: 'bottom', align: 'end' }"
214
+ >
215
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plug">
216
+ {{ t('contextAttachments.connectSource') }}
217
+ </UButton>
218
+ </UDropdownMenu>
219
+ <UButton
220
+ v-else-if="connectableIssueSources.length === 1"
221
+ color="neutral"
222
+ variant="soft"
223
+ size="xs"
224
+ icon="i-lucide-plug"
225
+ @click="ui.openTaskConnect(connectableIssueSources[0]!.source)"
226
+ >
227
+ {{
228
+ t('contextAttachments.connectSourceNamed', {
229
+ source: connectableIssueSources[0]!.label,
230
+ })
231
+ }}
232
+ </UButton>
233
+ <UButton
234
+ v-else
235
+ color="neutral"
236
+ variant="soft"
237
+ size="xs"
238
+ icon="i-lucide-plus"
239
+ disabled
240
+ :title="
241
+ tasks.available
242
+ ? t('contextAttachments.attachIssueDisabledConnect')
243
+ : t('contextAttachments.attachIssueDisabledEnable')
244
+ "
245
+ >
246
+ {{ t('contextAttachments.attach') }}
247
+ </UButton>
248
+ </div>
249
+ <ContextIssuePicker
250
+ v-if="showIssuePicker && issuesConnected"
251
+ :chosen-keys="chosenIssueKeys"
252
+ :scope-block-id="scopeBlockId"
253
+ @pick="addPending"
254
+ />
255
+ <div v-if="pendingIssues.length" class="space-y-1">
256
+ <div
257
+ v-for="item in pendingIssues"
258
+ :key="contextKey(item)"
259
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
260
+ >
261
+ <UIcon
262
+ :name="item.icon ?? 'i-lucide-square-check'"
263
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
264
+ />
265
+ <span class="truncate">{{ item.title }}</span>
266
+ <UBadge
267
+ v-if="item.needsImport"
268
+ color="neutral"
269
+ variant="soft"
270
+ size="xs"
271
+ class="ms-1 shrink-0"
272
+ >
273
+ {{ t('contextAttachments.importsOnAdd') }}
274
+ </UBadge>
275
+ <button
276
+ type="button"
277
+ class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
278
+ @click="removePending(item)"
279
+ >
280
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
281
+ </button>
282
+ </div>
283
+ </div>
284
+ <p v-else class="text-[11px] text-slate-500">
285
+ {{ issuesHint }}
286
+ </p>
287
+ </div>
288
+ </div>
289
+ </template>
@@ -2,16 +2,28 @@
2
2
  // The interactive document-interview window (WS5) — the dedicated view of the doc-authoring
3
3
  // INTERVIEWER gate. While the document run is parked between the outline and the draft, the
4
4
  // interviewer's clarifying questions (pending `qa` entries with an empty answer) are shown here;
5
- // the human answers them, then either CONTINUES (the interviewer re-runs and may ask follow-ups)
6
- // or PROCEEDS (skip remaining questions the interviewer converges into an authoring brief and
7
- // the run advances to the writer). Opened via the universal result-view host as the
5
+ // the human answers them, then either SUBMITS them (the `continue` action: the interviewer re-runs
6
+ // and may ask follow-ups) or drafts now (the `proceed` action: skip the remaining questions — the
7
+ // interviewer converges into an authoring brief and the run advances to the writer). The labels
8
+ // say submit/draft-now rather than continue/proceed because the latter pair both read as "go
9
+ // forward" and were indistinguishable in use. Opened via the universal result-view host as the
8
10
  // `doc-interviewer` step's result view. Live `docInterview` stream events patch the store, so an
9
11
  // open window follows the interview as it progresses. Mirrors InitiativePlanningWindow.vue.
12
+ //
13
+ // CONTINUE/PROCEED ARE ASYNC. They only record the intent on the parked step and wake the durable
14
+ // driver; the interviewer LLM then runs for as long as it takes, and the response carries the
15
+ // PRE-resume session. So the window must not key its body on the session alone — that renders
16
+ // identically before and after the click, which reads as the button having done nothing. The
17
+ // phase below folds the document RUN's status in, so the wait is visible and a failed pass says
18
+ // so instead of leaving the human staring at questions they already submitted.
10
19
  import { computed, reactive, watch } from 'vue'
20
+ import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
11
21
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
22
+ import { interviewGatePhase } from '~/utils/interviewGate'
12
23
 
13
24
  const board = useBoardStore()
14
25
  const docInterview = useDocInterviewStore()
26
+ const execution = useExecutionStore()
15
27
  const { t } = useI18n()
16
28
  const access = useWorkspaceAccess()
17
29
 
@@ -21,14 +33,13 @@ const { open, blockId, close } = useResultView('doc-interview', {
21
33
 
22
34
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
23
35
  const session = computed(() => (blockId.value ? docInterview.forBlock(blockId.value) : null))
36
+ const run = computed(() => (blockId.value ? execution.getByBlock(blockId.value) : undefined))
24
37
 
25
38
  /** Every interview exchange, with a stable key for the list + draft map. */
26
39
  const questions = computed(() =>
27
40
  (session.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
28
41
  )
29
42
  const pending = computed(() => questions.value.filter((q) => !(q.answer ?? '').trim()))
30
- /** The interview converged (or never started with a model): nothing left to answer. */
31
- const converged = computed(() => session.value?.status === 'done')
32
43
 
33
44
  // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive without
34
45
  // clobbering an answer the human is mid-edit on.
@@ -44,8 +55,31 @@ watch(
44
55
  )
45
56
 
46
57
  const resuming = computed(() => docInterview.resuming)
47
- /** Continue is meaningful once every pending question has a drafted answer. */
48
- const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
58
+
59
+ /**
60
+ * The live phase (see `interviewGatePhase`). `resuming` folds in the request itself so the body
61
+ * swaps to the waiting state on the click rather than a beat later when the run's `running` event
62
+ * lands — and if the request fails, `resuming` clears and the phase falls back to whatever the run
63
+ * actually says, so the questions come back rather than the window sticking on a spinner.
64
+ */
65
+ const phase = computed(() =>
66
+ resuming.value ? 'working' : interviewGatePhase(session.value?.status, run.value?.status),
67
+ )
68
+ /** The interview converged: the synthesized authoring brief is what the window shows. */
69
+ const converged = computed(() => phase.value === 'converged')
70
+
71
+ /**
72
+ * Questions still missing a drafted answer. Submit is only meaningful once this is empty — but a
73
+ * disabled button with no stated reason is itself a "nothing happened", so the count is rendered.
74
+ */
75
+ const unanswered = computed(() => pending.value.filter((q) => !drafts[q.key]?.trim()).length)
76
+
77
+ /** Why Submit is unavailable, or undefined when it is. RBAC first: it outranks a draft gap. */
78
+ const continueBlockedReason = computed(() => {
79
+ if (!access.canExecuteRuns.value) return t('access.noRunExecute')
80
+ if (unanswered.value > 0) return t('docInterview.unanswered', { count: unanswered.value })
81
+ return undefined
82
+ })
49
83
 
50
84
  /** Persist one answer if its draft differs from what's recorded. */
51
85
  async function persist(q: { id?: string; key: string; answer?: string }) {
@@ -99,9 +133,28 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
99
133
  {{ t('docInterview.intro') }}
100
134
  </p>
101
135
 
136
+ <!-- A pass is running: the human is waiting on the interviewer. Without this the window is
137
+ byte-identical to the parked state and the submit reads as a no-op. -->
138
+ <InterviewGateNotice
139
+ v-if="phase === 'working'"
140
+ variant="working"
141
+ :title="t('docInterview.working')"
142
+ :hint="t('docInterview.workingHint')"
143
+ testid="doc-interview-working"
144
+ />
145
+
146
+ <!-- The document run stopped before the interview settled — a dead end otherwise. -->
147
+ <InterviewGateNotice
148
+ v-else-if="phase === 'failed'"
149
+ variant="failed"
150
+ :title="t('docInterview.failed')"
151
+ :hint="t('docInterview.failedHint')"
152
+ testid="doc-interview-failed"
153
+ />
154
+
102
155
  <!-- Converged: show the synthesized authoring brief -->
103
156
  <div
104
- v-if="converged"
157
+ v-else-if="converged"
105
158
  class="rounded-lg border border-slate-800 bg-slate-950/40 p-4"
106
159
  data-testid="doc-interview-converged"
107
160
  >
@@ -146,13 +199,21 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
146
199
  </template>
147
200
  </div>
148
201
 
149
- <!-- Action rail -->
202
+ <!-- Action rail. Only while the run is actually parked on the human: mid-pass these would
203
+ re-submit a question set already in flight, and the resume is a no-op once it isn't. -->
150
204
  <footer
151
- v-if="session && !converged && questions.length > 0"
205
+ v-if="session && phase === 'awaiting' && questions.length > 0"
152
206
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
153
207
  >
154
208
  <p class="text-[11px] text-slate-500">
155
- {{ t('docInterview.hint') }}
209
+ <span
210
+ v-if="unanswered > 0"
211
+ class="text-amber-400/90"
212
+ data-testid="doc-interview-unanswered"
213
+ >
214
+ {{ t('docInterview.unanswered', { count: unanswered }) }}
215
+ </span>
216
+ <span v-else>{{ t('docInterview.hint') }}</span>
156
217
  </p>
157
218
  <div class="flex items-center gap-2">
158
219
  <UButton
@@ -161,7 +222,9 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
161
222
  size="sm"
162
223
  :loading="resuming"
163
224
  :disabled="!access.canExecuteRuns.value"
164
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
225
+ :title="
226
+ access.canExecuteRuns.value ? t('docInterview.proceedTitle') : t('access.noRunExecute')
227
+ "
165
228
  data-testid="doc-interview-proceed"
166
229
  @click="onProceed"
167
230
  >
@@ -171,8 +234,8 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
171
234
  color="primary"
172
235
  size="sm"
173
236
  :loading="resuming"
174
- :disabled="!allAnswered || !access.canExecuteRuns.value"
175
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
237
+ :disabled="!!continueBlockedReason"
238
+ :title="continueBlockedReason ?? t('docInterview.continueTitle')"
176
239
  data-testid="doc-interview-continue"
177
240
  @click="onContinue"
178
241
  >
@@ -6,7 +6,7 @@
6
6
  // the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
7
7
  // run. The account scope has no resolved/merged catalog and fetches document
8
8
  // fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
9
- import { computed, reactive, ref, watch } from 'vue'
9
+ import { computed, nextTick, reactive, ref, watch } from 'vue'
10
10
  import type {
11
11
  DocumentSourceKind,
12
12
  FragmentOwnerKind,
@@ -16,6 +16,7 @@ import type {
16
16
  import { useFragmentLibrary, useFragmentLibraryStore } from '~/stores/fragmentLibrary'
17
17
  import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
18
18
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
19
+ import GitHubDocUrlImport from '~/components/fragments/GitHubDocUrlImport.vue'
19
20
 
20
21
  const props = withDefaults(
21
22
  defineProps<{
@@ -274,6 +275,9 @@ const docRepo = ref<GitHubAvailableRepo | undefined>(undefined)
274
275
  // The staged files (repo-root-relative paths) for the selected repo. Multi-select so
275
276
  // any number of files from any nesting level can be linked in one action.
276
277
  const docFilePaths = ref<string[]>([])
278
+ // Where the tree browser opens (repo root by default); a pasted directory URL jumps it
279
+ // straight to the pasted folder so its files can be bulk-checked.
280
+ const docBrowsePath = ref('')
277
281
 
278
282
  // Reset the picker (and the manual ref) whenever the selected source changes.
279
283
  watch(
@@ -282,15 +286,24 @@ watch(
282
286
  docRepoId.value = undefined
283
287
  docRepo.value = undefined
284
288
  docFilePaths.value = []
289
+ docBrowsePath.value = ''
285
290
  docDraft.value.ref = ''
286
291
  },
287
292
  )
288
293
 
289
- // A new repo selection clears the previously-staged files (they were repo-scoped).
294
+ // A URL import wants the browser opened at the pasted folder in the SAME flush as its
295
+ // repo selection (so the tree loads once, at the right path); a manual repo switch
296
+ // wants the path reset instead. The import stages its target here for the watcher.
297
+ let pendingBrowsePath: string | null = null
298
+
299
+ // A new repo selection clears the previously-staged files (they were repo-scoped) and
300
+ // re-roots the browser (a folder from the previous repo has no meaning in the new one).
290
301
  // When the repo is fully deselected, drop the cached repo too so no stale `docRepo`
291
302
  // lingers behind a now-empty picker.
292
303
  watch(docRepoId, (id) => {
293
304
  docFilePaths.value = []
305
+ docBrowsePath.value = pendingBrowsePath ?? ''
306
+ pendingBrowsePath = null
294
307
  if (id === undefined) docRepo.value = undefined
295
308
  })
296
309
 
@@ -330,6 +343,58 @@ const stagedDocRefs = computed(() =>
330
343
  /** When the rich picker drives the ref(s); otherwise the free-text field does. */
331
344
  const usingDocPicker = computed(() => showGithubDocPicker.value)
332
345
 
346
+ /**
347
+ * A pasted GitHub file/directory URL resolved to a repo + location: select the repo
348
+ * (through the same refs the search select drives), then stage the file or jump the
349
+ * tree browser to the directory for bulk checking. Staging waits a tick because the
350
+ * `docRepoId` watcher clears the cart on a repo change.
351
+ */
352
+ async function onDocUrlResolved(target: {
353
+ repo: GitHubAvailableRepo
354
+ path: string
355
+ kind: 'file' | 'dir'
356
+ }) {
357
+ const clean = normalizeRepoPath(target.path)
358
+ // A file opens its parent folder (so the pick is visible in context); a dir opens itself.
359
+ const browseDir =
360
+ target.kind === 'dir'
361
+ ? clean
362
+ : clean.includes('/')
363
+ ? clean.slice(0, clean.lastIndexOf('/'))
364
+ : ''
365
+ if (docRepoId.value !== target.repo.githubId) {
366
+ pendingBrowsePath = browseDir
367
+ docRepo.value = target.repo
368
+ docRepoId.value = target.repo.githubId
369
+ // Let the repo-change watcher run (clears the cart, applies the browse path)
370
+ // before staging, or the staged file would be swept with the old repo's cart.
371
+ await nextTick()
372
+ } else {
373
+ docBrowsePath.value = browseDir
374
+ }
375
+ if (target.kind === 'file' && clean) {
376
+ if (!docFilePaths.value.includes(clean) && !docAddedPaths.value.includes(clean)) {
377
+ docFilePaths.value.push(clean)
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * The reason the "Link as living fragment" button is disabled, stated next to it —
384
+ * an inert button with no explanation reads as broken. Null when the button is live
385
+ * (or already busy linking).
386
+ */
387
+ const docLinkBlockedReason = computed<string | null>(() => {
388
+ if (linkingDoc.value || docDraftValid.value) return null
389
+ if (!docDraft.value.source) return t('fragments.documents.blockedReason.noSource')
390
+ if (usingDocPicker.value) {
391
+ if (docRepoId.value === undefined) return t('fragments.documents.blockedReason.noRepo')
392
+ return t('fragments.documents.blockedReason.noFiles')
393
+ }
394
+ if (!docDraft.value.ref.trim()) return t('fragments.documents.blockedReason.noRef')
395
+ return null
396
+ })
397
+
333
398
  async function linkDocumentFragment() {
334
399
  if (!docDraftValid.value) return
335
400
  linkingDoc.value = true
@@ -824,8 +889,10 @@ async function unlinkSource(id: string) {
824
889
  </UButton>
825
890
  </div>
826
891
 
827
- <!-- GitHub: search a repo + browse to one or more files instead of typing the ref -->
892
+ <!-- GitHub: paste a file/directory URL, or search a repo + browse to one or
893
+ more files, instead of typing the ref -->
828
894
  <template v-if="showGithubDocPicker">
895
+ <GitHubDocUrlImport @resolved="onDocUrlResolved" />
829
896
  <GitHubRepoSearchSelect v-model="docRepoId" @update:repo="docRepo = $event" />
830
897
  <div
831
898
  v-if="docRepoId !== undefined"
@@ -838,6 +905,7 @@ async function unlinkSource(id: string) {
838
905
  :repo-github-id="docRepoId"
839
906
  mode="file"
840
907
  multiple
908
+ :start-path="docBrowsePath"
841
909
  :selected-paths="docFilePaths"
842
910
  :added-paths="docAddedPaths"
843
911
  @toggle="toggleDocFile"
@@ -879,16 +947,26 @@ async function unlinkSource(id: string) {
879
947
  v-model="docDraft.tags"
880
948
  :placeholder="t('fragments.documents.tagsPlaceholder')"
881
949
  />
882
- <UButton
883
- icon="i-lucide-link"
884
- size="sm"
885
- :disabled="!docDraftValid"
886
- :loading="linkingDoc"
887
- class="self-start"
888
- @click="linkDocumentFragment"
889
- >
890
- {{ t('fragments.documents.link') }}
891
- </UButton>
950
+ <div class="flex items-center gap-2 self-start">
951
+ <UButton
952
+ icon="i-lucide-link"
953
+ size="sm"
954
+ :disabled="!docDraftValid"
955
+ :loading="linkingDoc"
956
+ data-testid="fragment-link-document"
957
+ @click="linkDocumentFragment"
958
+ >
959
+ {{ t('fragments.documents.link') }}
960
+ </UButton>
961
+ <p
962
+ v-if="docLinkBlockedReason"
963
+ class="flex items-center gap-1 text-xs text-slate-500"
964
+ data-testid="fragment-link-blocked-reason"
965
+ >
966
+ <UIcon name="i-lucide-info" class="h-3.5 w-3.5 shrink-0" />
967
+ {{ docLinkBlockedReason }}
968
+ </p>
969
+ </div>
892
970
  </div>
893
971
  </div>
894
972
  </div>