@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.
- package/README.md +4 -0
- package/app/components/board/AddTaskModal.vue +10 -252
- package/app/components/board/CreateInitiativeModal.vue +27 -0
- package/app/components/board/nodes/InitiativeCard.vue +14 -0
- package/app/components/common/InterviewGateNotice.vue +39 -0
- package/app/components/context/ContextAttachmentFields.vue +289 -0
- package/app/components/docs/DocInterviewWindow.vue +77 -14
- package/app/components/fragments/FragmentLibraryManager.vue +91 -13
- package/app/components/fragments/GitHubDocUrlImport.vue +83 -0
- package/app/components/github/RepoTreeBrowser.vue +65 -10
- package/app/components/initiative/InitiativePlanningWindow.vue +82 -13
- package/app/components/layout/DefaultTestEnvBanner.vue +117 -0
- package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
- package/app/components/settings/DefaultProvisionTypeSection.vue +176 -0
- package/app/components/settings/InfrastructureWindow.vue +8 -1
- package/app/composables/useContextLinking.ts +14 -2
- package/app/composables/useInitiativePlanning.ts +28 -6
- package/app/pages/index.vue +12 -1
- package/app/stores/ui/modals.ts +46 -0
- package/app/stores/workspaceSettings.ts +5 -0
- package/app/utils/defaultProvisioning.spec.ts +100 -0
- package/app/utils/defaultProvisioning.ts +99 -0
- package/app/utils/interviewGate.spec.ts +51 -0
- package/app/utils/interviewGate.ts +51 -0
- package/i18n/locales/de.json +70 -22
- package/i18n/locales/en.json +73 -22
- package/i18n/locales/es.json +70 -22
- package/i18n/locales/fr.json +70 -22
- package/i18n/locales/he.json +70 -22
- package/i18n/locales/it.json +70 -22
- package/i18n/locales/ja.json +70 -22
- package/i18n/locales/pl.json +70 -22
- package/i18n/locales/tr.json +70 -22
- package/i18n/locales/uk.json +70 -22
- 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
|
|
6
|
-
//
|
|
7
|
-
// the run advances to the writer).
|
|
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
|
-
|
|
48
|
-
|
|
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 &&
|
|
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
|
-
|
|
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="
|
|
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="
|
|
175
|
-
:title="
|
|
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
|
|
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
|
|
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
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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>
|