@cat-factory/app 0.172.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/panels/inspector/InitiativeInspector.vue +15 -0
- package/app/composables/useContextLinking.ts +14 -2
- package/app/composables/useInitiativePlanning.ts +28 -6
- package/app/utils/interviewGate.spec.ts +51 -0
- package/app/utils/interviewGate.ts +51 -0
- package/i18n/locales/de.json +56 -22
- package/i18n/locales/en.json +59 -22
- package/i18n/locales/es.json +56 -22
- package/i18n/locales/fr.json +56 -22
- package/i18n/locales/he.json +56 -22
- package/i18n/locales/it.json +56 -22
- package/i18n/locales/ja.json +56 -22
- package/i18n/locales/pl.json +56 -22
- package/i18n/locales/tr.json +56 -22
- package/i18n/locales/uk.json +56 -22
- package/package.json +2 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Paste a GitHub/GitLab file or directory URL to start a document-fragment link without
|
|
3
|
+
// the repo typeahead: the URL is parsed client-side (owner/repo + path + file-vs-dir) and
|
|
4
|
+
// the repo resolved through the SHARED available-repos list by its exact slug — the
|
|
5
|
+
// backend point-reads an exact `owner/name`, so this never depends on the provider's
|
|
6
|
+
// name search (where a pasted URL matches nothing). Emits the resolved repo + location;
|
|
7
|
+
// the manager stages a file or opens the tree browser at the directory for bulk picking.
|
|
8
|
+
import { parseRepoWebUrl } from '@cat-factory/contracts'
|
|
9
|
+
import type { GitHubAvailableRepo } from '~/types/domain'
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
resolved: [{ repo: GitHubAvailableRepo; path: string; kind: 'file' | 'dir' }]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const github = useGitHubStore()
|
|
17
|
+
|
|
18
|
+
const url = ref('')
|
|
19
|
+
const resolving = ref(false)
|
|
20
|
+
const error = ref<string | null>(null)
|
|
21
|
+
|
|
22
|
+
async function importUrl() {
|
|
23
|
+
const input = url.value.trim()
|
|
24
|
+
if (!input || resolving.value) return
|
|
25
|
+
const parsed = parseRepoWebUrl(input)
|
|
26
|
+
if (!parsed) {
|
|
27
|
+
error.value = t('fragments.documents.urlImport.invalid')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
resolving.value = true
|
|
31
|
+
error.value = null
|
|
32
|
+
const slug = `${parsed.owner}/${parsed.repo}`
|
|
33
|
+
try {
|
|
34
|
+
// Load into the SHARED picker list (not the side-effect-free search) so the repo
|
|
35
|
+
// select alongside this field can render the resolved selection's label.
|
|
36
|
+
await github.loadAvailableRepos(slug)
|
|
37
|
+
const repo = github.availableRepos.find(
|
|
38
|
+
(r) =>
|
|
39
|
+
r.owner.toLowerCase() === parsed.owner.toLowerCase() &&
|
|
40
|
+
r.name.toLowerCase() === parsed.repo.toLowerCase(),
|
|
41
|
+
)
|
|
42
|
+
if (!repo) {
|
|
43
|
+
error.value = t('fragments.documents.urlImport.notFound', { slug })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
emit('resolved', { repo, path: parsed.path, kind: parsed.kind })
|
|
47
|
+
url.value = ''
|
|
48
|
+
} catch (e) {
|
|
49
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
50
|
+
} finally {
|
|
51
|
+
resolving.value = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="flex flex-col gap-1">
|
|
58
|
+
<div class="flex gap-2">
|
|
59
|
+
<UInput
|
|
60
|
+
v-model="url"
|
|
61
|
+
icon="i-lucide-link-2"
|
|
62
|
+
:placeholder="t('fragments.documents.urlImport.placeholder')"
|
|
63
|
+
class="flex-1"
|
|
64
|
+
data-testid="fragment-url-import-input"
|
|
65
|
+
@keyup.enter="importUrl"
|
|
66
|
+
/>
|
|
67
|
+
<UButton
|
|
68
|
+
size="sm"
|
|
69
|
+
variant="outline"
|
|
70
|
+
icon="i-lucide-folder-search"
|
|
71
|
+
:loading="resolving"
|
|
72
|
+
:disabled="!url.trim()"
|
|
73
|
+
data-testid="fragment-url-import-button"
|
|
74
|
+
@click="importUrl"
|
|
75
|
+
>
|
|
76
|
+
{{ t('fragments.documents.urlImport.action') }}
|
|
77
|
+
</UButton>
|
|
78
|
+
</div>
|
|
79
|
+
<p v-if="error" class="text-xs text-red-400" data-testid="fragment-url-import-error">
|
|
80
|
+
{{ error }}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -62,6 +62,22 @@ const isEmpty = computed(() =>
|
|
|
62
62
|
props.mode === 'dir' ? dirEntries.value.length === 0 : treeEntries.value.length === 0,
|
|
63
63
|
)
|
|
64
64
|
|
|
65
|
+
// file + multiple: the bulk-pick header. "Select all" checks every file of the CURRENT
|
|
66
|
+
// listing that isn't already picked or added elsewhere; unchecking clears only this
|
|
67
|
+
// listing's picks (never the cart entries staged from other folders).
|
|
68
|
+
const selectableFiles = computed(() =>
|
|
69
|
+
props.mode === 'file' && props.multiple ? fileEntries.value.filter((e) => !isAdded(e.path)) : [],
|
|
70
|
+
)
|
|
71
|
+
const allSelected = computed(
|
|
72
|
+
() => selectableFiles.value.length > 0 && selectableFiles.value.every((e) => isPicked(e.path)),
|
|
73
|
+
)
|
|
74
|
+
function toggleAllFiles() {
|
|
75
|
+
const check = !allSelected.value
|
|
76
|
+
for (const entry of selectableFiles.value) {
|
|
77
|
+
if (isPicked(entry.path) !== check) emit('toggle', entry.path)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
const breadcrumbs = computed(() => {
|
|
66
82
|
const segments = currentPath.value ? currentPath.value.split('/') : []
|
|
67
83
|
let acc = ''
|
|
@@ -175,21 +191,60 @@ watch(
|
|
|
175
191
|
</UButton>
|
|
176
192
|
</li>
|
|
177
193
|
<template v-if="mode === 'file'">
|
|
194
|
+
<!-- multiple: a bulk header so a whole directory of documents is one click -->
|
|
178
195
|
<li
|
|
179
|
-
v-
|
|
180
|
-
|
|
181
|
-
class="flex items-center justify-between gap-2 px-3 py-1.5"
|
|
196
|
+
v-if="selectableFiles.length > 1"
|
|
197
|
+
class="flex items-center gap-2 bg-slate-900/60 px-3 py-1.5"
|
|
182
198
|
>
|
|
199
|
+
<UCheckbox
|
|
200
|
+
:model-value="allSelected"
|
|
201
|
+
:aria-label="
|
|
202
|
+
t(
|
|
203
|
+
'github.repoTree.selectAllFiles',
|
|
204
|
+
{ count: selectableFiles.length },
|
|
205
|
+
selectableFiles.length,
|
|
206
|
+
)
|
|
207
|
+
"
|
|
208
|
+
data-testid="repo-tree-select-all"
|
|
209
|
+
@update:model-value="toggleAllFiles"
|
|
210
|
+
/>
|
|
183
211
|
<button
|
|
184
212
|
type="button"
|
|
185
|
-
class="
|
|
186
|
-
|
|
187
|
-
:disabled="isAdded(entry.path)"
|
|
188
|
-
@click="pick(entry.path)"
|
|
213
|
+
class="text-xs text-slate-400 hover:text-primary-400"
|
|
214
|
+
@click="toggleAllFiles"
|
|
189
215
|
>
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
{{
|
|
217
|
+
t(
|
|
218
|
+
'github.repoTree.selectAllFiles',
|
|
219
|
+
{ count: selectableFiles.length },
|
|
220
|
+
selectableFiles.length,
|
|
221
|
+
)
|
|
222
|
+
}}
|
|
192
223
|
</button>
|
|
224
|
+
</li>
|
|
225
|
+
<li
|
|
226
|
+
v-for="entry in fileEntries"
|
|
227
|
+
:key="entry.path"
|
|
228
|
+
class="flex items-center justify-between gap-2 px-3 py-1.5"
|
|
229
|
+
>
|
|
230
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
231
|
+
<UCheckbox
|
|
232
|
+
v-if="multiple && !isAdded(entry.path)"
|
|
233
|
+
:model-value="isPicked(entry.path)"
|
|
234
|
+
:aria-label="entry.name"
|
|
235
|
+
@update:model-value="pick(entry.path)"
|
|
236
|
+
/>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
|
|
240
|
+
:class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
|
|
241
|
+
:disabled="isAdded(entry.path)"
|
|
242
|
+
@click="pick(entry.path)"
|
|
243
|
+
>
|
|
244
|
+
<UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
245
|
+
<span class="truncate">{{ entry.name }}</span>
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
193
248
|
<span
|
|
194
249
|
v-if="isAdded(entry.path)"
|
|
195
250
|
class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
|
|
@@ -198,7 +253,7 @@ watch(
|
|
|
198
253
|
{{ t('github.repoTree.added') }}
|
|
199
254
|
</span>
|
|
200
255
|
<UIcon
|
|
201
|
-
v-else-if="isPicked(entry.path)"
|
|
256
|
+
v-else-if="!multiple && isPicked(entry.path)"
|
|
202
257
|
name="i-lucide-check"
|
|
203
258
|
class="h-4 w-4 shrink-0 text-primary-400"
|
|
204
259
|
/>
|
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// The interactive-planning Q&A window (slice 2) — the dedicated view of the initiative
|
|
3
3
|
// INTERVIEWER gate. While the planning run is parked, the interviewer's clarifying questions
|
|
4
|
-
// (pending `qa` entries with an empty answer) are shown here; the human answers them, then
|
|
5
|
-
//
|
|
6
|
-
// remaining questions — the interviewer converges and the run
|
|
4
|
+
// (pending `qa` entries with an empty answer) are shown here; the human answers them, then either
|
|
5
|
+
// SUBMITS them (the `continue` action: the interviewer re-runs and may ask follow-ups) or plans
|
|
6
|
+
// now (the `proceed` action: skip the remaining questions — the interviewer converges and the run
|
|
7
|
+
// advances to the analyst/planner). The labels say submit/plan-now rather than continue/proceed
|
|
8
|
+
// because the latter pair both read as "go forward" and were indistinguishable in use.
|
|
7
9
|
// Opened via the universal result-view host: from the inspector / card
|
|
8
10
|
// (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
|
|
9
11
|
// stream events patch the store, so an open window follows the interview as it progresses.
|
|
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 entity. So the window must not key its body on the entity alone — that renders
|
|
16
|
+
// identically before and after the click, which reads as the button having done nothing. The
|
|
17
|
+
// phase below folds the planning 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'
|
|
11
20
|
import ClarificationItem from '~/components/common/ClarificationItem.vue'
|
|
21
|
+
import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
|
|
12
22
|
import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
|
|
23
|
+
import { interviewGatePhase } from '~/utils/interviewGate'
|
|
13
24
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
14
25
|
|
|
15
26
|
const board = useBoardStore()
|
|
16
27
|
const initiatives = useInitiativesStore()
|
|
28
|
+
const execution = useExecutionStore()
|
|
17
29
|
const { t } = useI18n()
|
|
18
30
|
|
|
19
31
|
const { open, blockId, close } = useResultView('initiative-planning', {
|
|
@@ -22,6 +34,7 @@ const { open, blockId, close } = useResultView('initiative-planning', {
|
|
|
22
34
|
|
|
23
35
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
24
36
|
const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
|
|
37
|
+
const run = computed(() => (blockId.value ? execution.getByBlock(blockId.value) : undefined))
|
|
25
38
|
|
|
26
39
|
/** Every interview exchange, with a stable key for the list + draft map. */
|
|
27
40
|
const questions = computed(() =>
|
|
@@ -31,8 +44,6 @@ const questions = computed(() =>
|
|
|
31
44
|
const pending = computed(() =>
|
|
32
45
|
questions.value.filter((q) => q.status !== 'dismissed' && !(q.answer ?? '').trim()),
|
|
33
46
|
)
|
|
34
|
-
/** The interview converged (or never started with a model): nothing left to answer. */
|
|
35
|
-
const converged = computed(() => initiative.value?.interview?.status === 'done')
|
|
36
47
|
|
|
37
48
|
// Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
|
|
38
49
|
// without clobbering an answer the human is mid-edit on.
|
|
@@ -48,11 +59,26 @@ watch(
|
|
|
48
59
|
)
|
|
49
60
|
|
|
50
61
|
const resuming = computed(() => initiatives.resuming)
|
|
62
|
+
|
|
51
63
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
64
|
+
* The live phase (see `interviewGatePhase`). `resuming` folds in the request itself so the
|
|
65
|
+
* body swaps to the waiting state on the click rather than a beat later when the run's `running`
|
|
66
|
+
* event lands — and if the request fails, `resuming` clears and the phase falls back to whatever
|
|
67
|
+
* the run actually says, so the questions come back rather than the window sticking on a spinner.
|
|
54
68
|
*/
|
|
55
|
-
const
|
|
69
|
+
const phase = computed(() =>
|
|
70
|
+
resuming.value
|
|
71
|
+
? 'working'
|
|
72
|
+
: interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Questions still missing a drafted answer. Continue is only meaningful once this is empty — but a
|
|
77
|
+
* disabled button with no stated reason is itself a "nothing happened", so the count is rendered.
|
|
78
|
+
* A dismissed question doesn't count (it was set aside), so an all-dismissed round is trivially
|
|
79
|
+
* answered.
|
|
80
|
+
*/
|
|
81
|
+
const unanswered = computed(() => pending.value.filter((q) => !drafts[q.key]?.trim()).length)
|
|
56
82
|
|
|
57
83
|
/**
|
|
58
84
|
* Persist one answer if its draft differs from what's recorded. A `dismissed` question is skipped:
|
|
@@ -135,9 +161,38 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
135
161
|
{{ t('initiative.planning.intro') }}
|
|
136
162
|
</p>
|
|
137
163
|
|
|
164
|
+
<!-- A pass is running: the human is waiting on the planner. Without this the window is
|
|
165
|
+
byte-identical to the parked state and the submit reads as a no-op. -->
|
|
166
|
+
<InterviewGateNotice
|
|
167
|
+
v-if="phase === 'working'"
|
|
168
|
+
variant="working"
|
|
169
|
+
:title="t('initiative.planning.working')"
|
|
170
|
+
:hint="t('initiative.planning.workingHint')"
|
|
171
|
+
testid="initiative-planning-working"
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<!-- The planning run stopped before the interview settled — a dead end otherwise. -->
|
|
175
|
+
<InterviewGateNotice
|
|
176
|
+
v-else-if="phase === 'failed'"
|
|
177
|
+
variant="failed"
|
|
178
|
+
:title="t('initiative.planning.failed')"
|
|
179
|
+
:hint="t('initiative.planning.failedHint')"
|
|
180
|
+
testid="initiative-planning-failed"
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
<!-- Planning was never started, so there is nothing to answer YET (distinct from
|
|
184
|
+
converged, which means the planner already has what it needs). -->
|
|
185
|
+
<div
|
|
186
|
+
v-else-if="phase === 'idle' && questions.length === 0"
|
|
187
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
188
|
+
data-testid="initiative-planning-idle"
|
|
189
|
+
>
|
|
190
|
+
{{ t('initiative.planning.idle') }}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
138
193
|
<!-- Converged / no pending questions -->
|
|
139
194
|
<div
|
|
140
|
-
v-if="converged || questions.length === 0"
|
|
195
|
+
v-else-if="phase === 'converged' || questions.length === 0"
|
|
141
196
|
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
142
197
|
data-testid="initiative-planning-converged"
|
|
143
198
|
>
|
|
@@ -166,13 +221,21 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
166
221
|
</template>
|
|
167
222
|
</div>
|
|
168
223
|
|
|
169
|
-
<!-- Action rail
|
|
224
|
+
<!-- Action rail. Only while the run is actually parked on the human: mid-pass these would
|
|
225
|
+
re-submit a question set already in flight, and the resume is a no-op once it isn't. -->
|
|
170
226
|
<footer
|
|
171
|
-
v-if="initiative &&
|
|
227
|
+
v-if="initiative && phase === 'awaiting' && questions.length > 0"
|
|
172
228
|
class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
|
|
173
229
|
>
|
|
174
230
|
<p class="text-[11px] text-slate-500">
|
|
175
|
-
|
|
231
|
+
<span
|
|
232
|
+
v-if="unanswered > 0"
|
|
233
|
+
class="text-amber-400/90"
|
|
234
|
+
data-testid="initiative-planning-unanswered"
|
|
235
|
+
>
|
|
236
|
+
{{ t('initiative.planning.unanswered', { count: unanswered }) }}
|
|
237
|
+
</span>
|
|
238
|
+
<span v-else>{{ t('initiative.planning.hint') }}</span>
|
|
176
239
|
</p>
|
|
177
240
|
<div class="flex items-center gap-2">
|
|
178
241
|
<UButton
|
|
@@ -180,6 +243,7 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
180
243
|
variant="ghost"
|
|
181
244
|
size="sm"
|
|
182
245
|
:loading="resuming"
|
|
246
|
+
:title="t('initiative.planning.proceedTitle')"
|
|
183
247
|
data-testid="initiative-planning-proceed"
|
|
184
248
|
@click="onProceed"
|
|
185
249
|
>
|
|
@@ -189,7 +253,12 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
189
253
|
color="primary"
|
|
190
254
|
size="sm"
|
|
191
255
|
:loading="resuming"
|
|
192
|
-
:disabled="
|
|
256
|
+
:disabled="unanswered > 0"
|
|
257
|
+
:title="
|
|
258
|
+
unanswered > 0
|
|
259
|
+
? t('initiative.planning.unanswered', { count: unanswered })
|
|
260
|
+
: t('initiative.planning.continueTitle')
|
|
261
|
+
"
|
|
193
262
|
data-testid="initiative-planning-continue"
|
|
194
263
|
@click="onContinue"
|
|
195
264
|
>
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
planningPipeline,
|
|
24
24
|
running,
|
|
25
25
|
awaitingAnswers,
|
|
26
|
+
interviewing,
|
|
26
27
|
starting,
|
|
27
28
|
runPlanning,
|
|
28
29
|
openPlanning,
|
|
@@ -66,6 +67,20 @@ function control(action: 'pause' | 'resume' | 'cancel') {
|
|
|
66
67
|
>
|
|
67
68
|
{{ t('initiative.inspector.answerPlanning') }}
|
|
68
69
|
</UButton>
|
|
70
|
+
<!-- Mid-pass there is nothing to answer, but the window must stay reachable — it is where
|
|
71
|
+
the "planner is working" state is shown. -->
|
|
72
|
+
<UButton
|
|
73
|
+
v-else-if="interviewing"
|
|
74
|
+
data-testid="initiative-planning-in-progress"
|
|
75
|
+
color="neutral"
|
|
76
|
+
variant="soft"
|
|
77
|
+
size="sm"
|
|
78
|
+
icon="i-lucide-loader-circle"
|
|
79
|
+
:ui="{ leadingIcon: 'animate-spin' }"
|
|
80
|
+
@click="openPlanning"
|
|
81
|
+
>
|
|
82
|
+
{{ t('initiative.inspector.planningInProgress') }}
|
|
83
|
+
</UButton>
|
|
69
84
|
<UButton
|
|
70
85
|
data-testid="initiative-run-planning"
|
|
71
86
|
color="primary"
|
|
@@ -161,8 +161,18 @@ export function useContextLinking() {
|
|
|
161
161
|
* reasons as the body, and a "Copy details" action that puts the full diagnostic
|
|
162
162
|
* report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
|
|
163
163
|
* so the cause stays readable long enough to act on. No-op when nothing failed.
|
|
164
|
+
*
|
|
165
|
+
* `opts.title` names what WAS created, which differs per host ("Task added, but …" vs
|
|
166
|
+
* "Initiative created, but …"). It is a resolver over the count rather than a message key, so
|
|
167
|
+
* each caller keeps a literal key at its own translation call site — passing the key through
|
|
168
|
+
* would make it a variable, which defeats both the typed-message-key check and the extractor's
|
|
169
|
+
* static scan — and the plural choice has to be made against the same count.
|
|
164
170
|
*/
|
|
165
|
-
function presentLinkFailures(
|
|
171
|
+
function presentLinkFailures(
|
|
172
|
+
failures: LinkFailure[],
|
|
173
|
+
blockId?: string,
|
|
174
|
+
opts: { title?: (count: number) => string } = {},
|
|
175
|
+
): void {
|
|
166
176
|
if (failures.length === 0) return
|
|
167
177
|
const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
|
|
168
178
|
const report = buildLinkFailureReport(failures, {
|
|
@@ -171,7 +181,9 @@ export function useContextLinking() {
|
|
|
171
181
|
when: new Date().toISOString(),
|
|
172
182
|
})
|
|
173
183
|
toast.add({
|
|
174
|
-
title:
|
|
184
|
+
title:
|
|
185
|
+
opts.title?.(failures.length) ??
|
|
186
|
+
t('board.addTask.linkFailed', { count: failures.length }, failures.length),
|
|
175
187
|
description,
|
|
176
188
|
icon: 'i-lucide-triangle-alert',
|
|
177
189
|
color: 'warning',
|
|
@@ -4,6 +4,7 @@ import { useExecutionStore } from '~/stores/execution'
|
|
|
4
4
|
import { useInitiativesStore } from '~/stores/initiative'
|
|
5
5
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
6
|
import { useUiStore } from '~/stores/ui'
|
|
7
|
+
import { interviewGatePhase } from '~/utils/interviewGate'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Shared planning affordances for an `initiative`-level block, used by BOTH the board card
|
|
@@ -37,13 +38,32 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
37
38
|
const running = computed(() => !!block.value?.executionId)
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* The
|
|
41
|
-
*
|
|
42
|
-
* "Answer planning questions" affordance stays available even after every question is filled but
|
|
43
|
-
* before the human resumes. Gating on unanswered questions would hide the only path back to the
|
|
44
|
-
* interview window once all are answered, stranding the still-parked run.
|
|
41
|
+
* The live interview phase, derived from the entity AND the planning run (see
|
|
42
|
+
* {@link interviewGatePhase} for why the run status is load-bearing).
|
|
45
43
|
*/
|
|
46
|
-
const
|
|
44
|
+
const interviewPhase = computed(() =>
|
|
45
|
+
interviewGatePhase(
|
|
46
|
+
initiative.value?.interview?.status,
|
|
47
|
+
execution.getByBlock(toValue(blockId))?.status,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
|
|
53
|
+
* questions are still blank — the "Answer planning questions" affordance must stay available
|
|
54
|
+
* after every question is filled but before the human resumes, or the only path back to the
|
|
55
|
+
* interview window disappears and the still-parked run is stranded.
|
|
56
|
+
*
|
|
57
|
+
* It IS keyed on the run not being mid-pass: after a continue/proceed the entity still reads
|
|
58
|
+
* `awaiting` for the whole (slow) interviewer pass, so an entity-only reading keeps the card
|
|
59
|
+
* pulsing and offering "Answer planning questions" over a question set that is already
|
|
60
|
+
* submitted and about to be replaced. {@link interviewing} covers that window instead, and a
|
|
61
|
+
* pass that fails takes the run out of `running`, so this comes back rather than stranding.
|
|
62
|
+
*/
|
|
63
|
+
const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
|
|
64
|
+
|
|
65
|
+
/** An interviewer pass is running — the human is waiting on the planner, not the reverse. */
|
|
66
|
+
const interviewing = computed(() => interviewPhase.value === 'working')
|
|
47
67
|
|
|
48
68
|
/**
|
|
49
69
|
* Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
|
|
@@ -82,7 +102,9 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
82
102
|
return {
|
|
83
103
|
planningPipeline,
|
|
84
104
|
running,
|
|
105
|
+
interviewPhase,
|
|
85
106
|
awaitingAnswers,
|
|
107
|
+
interviewing,
|
|
86
108
|
starting,
|
|
87
109
|
runPlanning,
|
|
88
110
|
openPlanning,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { interviewGatePhase } from './interviewGate'
|
|
3
|
+
|
|
4
|
+
// `interviewGatePhase` is what stops continue/proceed reading as no-ops in BOTH interview windows
|
|
5
|
+
// (initiative planning, document interview): the resume is asynchronous — the HTTP call only wakes
|
|
6
|
+
// the durable driver, so it returns the PRE-resume entity — and these pin that the RUN status is
|
|
7
|
+
// what distinguishes "parked, waiting on you" from "a pass is running", a distinction the entity
|
|
8
|
+
// alone cannot make.
|
|
9
|
+
|
|
10
|
+
describe('interviewGatePhase', () => {
|
|
11
|
+
it('is awaiting while the run is parked on the human', () => {
|
|
12
|
+
expect(interviewGatePhase('awaiting', 'blocked')).toBe('awaiting')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is working once the resumed run is running again, even though the entity still says awaiting', () => {
|
|
16
|
+
// The exact regression: continue/proceed leave the entity's status untouched until the pass
|
|
17
|
+
// finishes, so an entity-only reading renders the same questions and looks like a dead button.
|
|
18
|
+
expect(interviewGatePhase('awaiting', 'running')).toBe('working')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is working for the FIRST pass, before any question exists', () => {
|
|
22
|
+
expect(interviewGatePhase(undefined, 'running')).toBe('working')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('is failed when the run stopped before the interview settled', () => {
|
|
26
|
+
// Must not stay `working`: a pass that dies would otherwise spin forever.
|
|
27
|
+
expect(interviewGatePhase('awaiting', 'failed')).toBe('failed')
|
|
28
|
+
expect(interviewGatePhase(undefined, 'failed')).toBe('failed')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('is converged once the interview settled, whatever the run went on to do', () => {
|
|
32
|
+
// `converged` outranks `failed`: a later step's failure belongs to that step, not the
|
|
33
|
+
// interview, and the block's own failure surface reports it.
|
|
34
|
+
expect(interviewGatePhase('done', 'running')).toBe('converged')
|
|
35
|
+
expect(interviewGatePhase('done', 'failed')).toBe('converged')
|
|
36
|
+
expect(interviewGatePhase('done', undefined)).toBe('converged')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('is idle when the interview never ran', () => {
|
|
40
|
+
expect(interviewGatePhase(undefined, undefined)).toBe('idle')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('degrades to the entity-only reading when the run is not cached', () => {
|
|
44
|
+
// A window opened before the execution snapshot lands must show the questions, never a spinner.
|
|
45
|
+
expect(interviewGatePhase('awaiting', undefined)).toBe('awaiting')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps a paused run answerable', () => {
|
|
49
|
+
expect(interviewGatePhase('awaiting', 'paused')).toBe('awaiting')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
// The frontend dual of the backend's shared `InterviewGateController` spine. Both interview gates
|
|
4
|
+
// — the initiative-planning interviewer and the document interviewer — park their run on a
|
|
5
|
+
// decision-wait, expose the SAME `awaiting | done` status on their entity, and resume the same
|
|
6
|
+
// way, so how their windows read "what is happening right now" is shared vocabulary rather than
|
|
7
|
+
// two copies. See `docs/initiatives/clarification-items.md`.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What an interview gate is doing right now, from the human's point of view. Drives the interview
|
|
11
|
+
* window's body AND (for the initiative) the card/inspector affordances, so the surfaces can't
|
|
12
|
+
* disagree about whether there is anything to answer.
|
|
13
|
+
*
|
|
14
|
+
* - `idle` — the interview has not run yet (nothing to answer, nothing in flight).
|
|
15
|
+
* - `working` — an interviewer pass is running; the human waits.
|
|
16
|
+
* - `awaiting` — the run is parked on the human's answers.
|
|
17
|
+
* - `converged` — the interview settled; the run moved on.
|
|
18
|
+
* - `failed` — the run stopped before the interview settled.
|
|
19
|
+
*/
|
|
20
|
+
export type InterviewGatePhase = 'idle' | 'working' | 'awaiting' | 'converged' | 'failed'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the phase from the interview entity's status AND its run's status.
|
|
24
|
+
*
|
|
25
|
+
* The RUN status is load-bearing, not redundant. Continue/proceed are ASYNC by design: the HTTP
|
|
26
|
+
* call only records the intent on the parked step and wakes the durable driver, which then runs
|
|
27
|
+
* the (slow) interviewer LLM — so the response carries the PRE-resume entity, with the same
|
|
28
|
+
* questions and the same `awaiting` status. Keyed on the entity alone a window is therefore
|
|
29
|
+
* byte-identical before and after the click, which is indistinguishable from the button doing
|
|
30
|
+
* nothing for however long the pass takes. A resumed run flips `blocked` → `running` and emits,
|
|
31
|
+
* so `running` while the interview is unsettled is exactly "a pass is in flight".
|
|
32
|
+
*
|
|
33
|
+
* Deriving this from the run rather than a local in-flight flag also survives a reload and cannot
|
|
34
|
+
* wedge: a pass that FAILS takes the run to `failed`, so the window drops out of `working` and
|
|
35
|
+
* says so instead of spinning forever. An unknown run (no instance cached yet) degrades to the
|
|
36
|
+
* entity-only reading, never to a spinner.
|
|
37
|
+
*
|
|
38
|
+
* `converged` wins over `failed` on purpose: once the interview settled, a later failure belongs
|
|
39
|
+
* to the step that failed (the analyst/planner, the writer), and the block's own failure surface
|
|
40
|
+
* reports it — the interview window claiming the interview broke would be wrong.
|
|
41
|
+
*/
|
|
42
|
+
export function interviewGatePhase(
|
|
43
|
+
status: 'awaiting' | 'done' | undefined,
|
|
44
|
+
runStatus: ExecutionInstance['status'] | undefined,
|
|
45
|
+
): InterviewGatePhase {
|
|
46
|
+
if (status === 'done') return 'converged'
|
|
47
|
+
if (runStatus === 'failed') return 'failed'
|
|
48
|
+
if (runStatus === 'running') return 'working'
|
|
49
|
+
if (status === 'awaiting') return 'awaiting'
|
|
50
|
+
return 'idle'
|
|
51
|
+
}
|