@cat-factory/app 0.172.0 → 0.174.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/focus/BlockFocusView.vue +35 -1
- 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 +127 -16
- package/app/components/panels/InspectorPanel.vue +17 -3
- package/app/components/panels/inspector/InitiativeInspector.vue +15 -0
- package/app/components/panels/inspector/TaskExecution.vue +5 -20
- package/app/composables/useBlockDeletion.ts +8 -1
- package/app/composables/useContextLinking.ts +14 -2
- package/app/composables/useInitiativePlanning.ts +28 -6
- package/app/composables/useRunReset.ts +48 -0
- package/app/modular/panels/inspector.logic.spec.ts +8 -2
- package/app/modular/panels/inspector.logic.ts +14 -2
- package/app/stores/observability.ts +4 -0
- package/app/utils/interviewGate.spec.ts +51 -0
- package/app/utils/interviewGate.ts +51 -0
- package/i18n/locales/de.json +66 -25
- package/i18n/locales/en.json +69 -25
- package/i18n/locales/es.json +66 -25
- package/i18n/locales/fr.json +66 -25
- package/i18n/locales/he.json +66 -25
- package/i18n/locales/it.json +66 -25
- package/i18n/locales/ja.json +66 -25
- package/i18n/locales/pl.json +66 -25
- package/i18n/locales/tr.json +66 -25
- package/i18n/locales/uk.json +66 -25
- 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
|
+
|
|
63
|
+
/**
|
|
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.
|
|
68
|
+
*/
|
|
69
|
+
const phase = computed(() =>
|
|
70
|
+
resuming.value
|
|
71
|
+
? 'working'
|
|
72
|
+
: interviewGatePhase(initiative.value?.interview?.status, run.value?.status),
|
|
73
|
+
)
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
|
-
* Continue is meaningful once
|
|
53
|
-
*
|
|
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.
|
|
54
80
|
*/
|
|
55
|
-
const
|
|
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:
|
|
@@ -101,6 +127,25 @@ async function flushThen(action: (id: string) => Promise<unknown>) {
|
|
|
101
127
|
|
|
102
128
|
const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
|
|
103
129
|
const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The escape hatch for a planning run that stalled. It belongs HERE, not only in the inspector's
|
|
133
|
+
* execution panel behind this window: submit and plan-now are the two things that wedge, so the
|
|
134
|
+
* human who needs a way out is looking at exactly this footer. Offered whenever a run still owns
|
|
135
|
+
* the block — including mid-pass and after a failed pass, which is where a wedge actually shows up
|
|
136
|
+
* and where neither of the other two buttons is even rendered.
|
|
137
|
+
*
|
|
138
|
+
* Discarding returns the block to `planned`, which re-enables "Run planning"; the interviewer gate
|
|
139
|
+
* drops the previous run's round bookkeeping on that fresh start, so the re-run genuinely
|
|
140
|
+
* re-interviews instead of force-converging on its first pass. Close on success — leaving the
|
|
141
|
+
* window open on the now-empty idle state would read as another dead end.
|
|
142
|
+
*/
|
|
143
|
+
const { resetting, resetRun } = useRunReset()
|
|
144
|
+
const canDiscard = computed(() => !!block.value?.executionId)
|
|
145
|
+
async function onDiscard() {
|
|
146
|
+
if (!blockId.value) return
|
|
147
|
+
if (await resetRun(blockId.value)) close()
|
|
148
|
+
}
|
|
104
149
|
</script>
|
|
105
150
|
|
|
106
151
|
<template>
|
|
@@ -135,9 +180,38 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
135
180
|
{{ t('initiative.planning.intro') }}
|
|
136
181
|
</p>
|
|
137
182
|
|
|
183
|
+
<!-- A pass is running: the human is waiting on the planner. Without this the window is
|
|
184
|
+
byte-identical to the parked state and the submit reads as a no-op. -->
|
|
185
|
+
<InterviewGateNotice
|
|
186
|
+
v-if="phase === 'working'"
|
|
187
|
+
variant="working"
|
|
188
|
+
:title="t('initiative.planning.working')"
|
|
189
|
+
:hint="t('initiative.planning.workingHint')"
|
|
190
|
+
testid="initiative-planning-working"
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
<!-- The planning run stopped before the interview settled — a dead end otherwise. -->
|
|
194
|
+
<InterviewGateNotice
|
|
195
|
+
v-else-if="phase === 'failed'"
|
|
196
|
+
variant="failed"
|
|
197
|
+
:title="t('initiative.planning.failed')"
|
|
198
|
+
:hint="t('initiative.planning.failedHint')"
|
|
199
|
+
testid="initiative-planning-failed"
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<!-- Planning was never started, so there is nothing to answer YET (distinct from
|
|
203
|
+
converged, which means the planner already has what it needs). -->
|
|
204
|
+
<div
|
|
205
|
+
v-else-if="phase === 'idle' && questions.length === 0"
|
|
206
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
207
|
+
data-testid="initiative-planning-idle"
|
|
208
|
+
>
|
|
209
|
+
{{ t('initiative.planning.idle') }}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
138
212
|
<!-- Converged / no pending questions -->
|
|
139
213
|
<div
|
|
140
|
-
v-if="converged || questions.length === 0"
|
|
214
|
+
v-else-if="phase === 'converged' || questions.length === 0"
|
|
141
215
|
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
142
216
|
data-testid="initiative-planning-converged"
|
|
143
217
|
>
|
|
@@ -166,20 +240,52 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
166
240
|
</template>
|
|
167
241
|
</div>
|
|
168
242
|
|
|
169
|
-
<!-- Action rail
|
|
243
|
+
<!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
|
|
244
|
+
human: mid-pass they would re-submit a question set already in flight, and the resume is a
|
|
245
|
+
no-op once it isn't. Discard is the opposite — it is offered for as long as a run owns the
|
|
246
|
+
block, because the phases where those two are hidden (working, failed) are exactly the ones
|
|
247
|
+
a wedged run sits in. -->
|
|
170
248
|
<footer
|
|
171
|
-
v-if="initiative &&
|
|
249
|
+
v-if="initiative && (canDiscard || (phase === 'awaiting' && questions.length > 0))"
|
|
172
250
|
class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
|
|
173
251
|
>
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
252
|
+
<UButton
|
|
253
|
+
v-if="canDiscard"
|
|
254
|
+
color="error"
|
|
255
|
+
variant="ghost"
|
|
256
|
+
size="sm"
|
|
257
|
+
icon="i-lucide-trash-2"
|
|
258
|
+
:loading="resetting"
|
|
259
|
+
:disabled="resuming"
|
|
260
|
+
:title="t('initiative.planning.discardTitle')"
|
|
261
|
+
data-testid="initiative-planning-discard"
|
|
262
|
+
@click="onDiscard"
|
|
263
|
+
>
|
|
264
|
+
{{ t('initiative.planning.discard') }}
|
|
265
|
+
</UButton>
|
|
266
|
+
<!-- `ms-auto` rather than relying on `justify-between`: discard is conditional, and without
|
|
267
|
+
it this group left-aligns on the (transient) render where it is the only child. -->
|
|
268
|
+
<div
|
|
269
|
+
v-if="phase === 'awaiting' && questions.length > 0"
|
|
270
|
+
class="ms-auto flex items-center gap-2"
|
|
271
|
+
>
|
|
272
|
+
<p class="text-[11px] text-slate-500">
|
|
273
|
+
<span
|
|
274
|
+
v-if="unanswered > 0"
|
|
275
|
+
class="text-amber-400/90"
|
|
276
|
+
data-testid="initiative-planning-unanswered"
|
|
277
|
+
>
|
|
278
|
+
{{ t('initiative.planning.unanswered', { count: unanswered }) }}
|
|
279
|
+
</span>
|
|
280
|
+
<span v-else>{{ t('initiative.planning.hint') }}</span>
|
|
281
|
+
</p>
|
|
178
282
|
<UButton
|
|
179
283
|
color="neutral"
|
|
180
284
|
variant="ghost"
|
|
181
285
|
size="sm"
|
|
182
286
|
:loading="resuming"
|
|
287
|
+
:disabled="resetting"
|
|
288
|
+
:title="t('initiative.planning.proceedTitle')"
|
|
183
289
|
data-testid="initiative-planning-proceed"
|
|
184
290
|
@click="onProceed"
|
|
185
291
|
>
|
|
@@ -189,7 +295,12 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
189
295
|
color="primary"
|
|
190
296
|
size="sm"
|
|
191
297
|
:loading="resuming"
|
|
192
|
-
:disabled="
|
|
298
|
+
:disabled="unanswered > 0 || resetting"
|
|
299
|
+
:title="
|
|
300
|
+
unanswered > 0
|
|
301
|
+
? t('initiative.planning.unanswered', { count: unanswered })
|
|
302
|
+
: t('initiative.planning.continueTitle')
|
|
303
|
+
"
|
|
193
304
|
data-testid="initiative-planning-continue"
|
|
194
305
|
@click="onContinue"
|
|
195
306
|
>
|
|
@@ -60,6 +60,14 @@ watch(
|
|
|
60
60
|
)
|
|
61
61
|
const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
|
|
62
62
|
const isTask = computed(() => level.value === 'task')
|
|
63
|
+
const isInitiative = computed(() => level.value === 'initiative')
|
|
64
|
+
/**
|
|
65
|
+
* Blocks whose inspector carries a pipeline RUN — a task, and an initiative (whose planning
|
|
66
|
+
* pipeline is an ordinary run of ordinary agent steps). Both get the execution panel and the
|
|
67
|
+
* Focus view; what differs is only how the run is STARTED (a task picks any pipeline, an
|
|
68
|
+
* initiative may only run its planning one, so it keeps its own "Run planning" control).
|
|
69
|
+
*/
|
|
70
|
+
const hasRuns = computed(() => isTask.value || isInitiative.value)
|
|
63
71
|
|
|
64
72
|
const instance = computed(() => execution.getInstance(block.value?.executionId))
|
|
65
73
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
@@ -104,7 +112,10 @@ const runBlockedReason = computed(() => {
|
|
|
104
112
|
const canRun = computed(() => runnable.value && access.canExecuteRuns.value)
|
|
105
113
|
|
|
106
114
|
// The delete control names what it removes, so selecting a task and deleting it
|
|
107
|
-
// reads as "Delete task" rather than ambiguously removing the whole service.
|
|
115
|
+
// reads as "Delete task" rather than ambiguously removing the whole service. An
|
|
116
|
+
// initiative is its own level (it hangs off a frame like a module does), so it must
|
|
117
|
+
// name ITSELF — offering to "delete service" there describes the wrong blast radius
|
|
118
|
+
// entirely: the frame and every other thing under it survive.
|
|
108
119
|
const deleteLabel = computed(() =>
|
|
109
120
|
schedule.value
|
|
110
121
|
? t('panels.inspector.deleteRecurringPipeline')
|
|
@@ -112,7 +123,9 @@ const deleteLabel = computed(() =>
|
|
|
112
123
|
? t('panels.inspector.deleteTask')
|
|
113
124
|
: level.value === 'module'
|
|
114
125
|
? t('panels.inspector.deleteModule')
|
|
115
|
-
:
|
|
126
|
+
: isInitiative.value
|
|
127
|
+
? t('panels.inspector.deleteInitiative')
|
|
128
|
+
: t('panels.inspector.deleteService'),
|
|
116
129
|
)
|
|
117
130
|
|
|
118
131
|
// A task is "started" once a pipeline has been launched on it (it has an
|
|
@@ -543,11 +556,12 @@ const showOriginalDescription = ref(false)
|
|
|
543
556
|
</UButton>
|
|
544
557
|
</UDropdownMenu>
|
|
545
558
|
<UButton
|
|
546
|
-
v-if="
|
|
559
|
+
v-if="hasRuns"
|
|
547
560
|
color="neutral"
|
|
548
561
|
variant="soft"
|
|
549
562
|
size="sm"
|
|
550
563
|
icon="i-lucide-maximize-2"
|
|
564
|
+
data-testid="inspector-focus"
|
|
551
565
|
@click="ui.focus(block.id)"
|
|
552
566
|
>
|
|
553
567
|
{{ t('panels.inspector.focus') }}
|
|
@@ -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"
|
|
@@ -192,26 +192,11 @@ async function stopRun() {
|
|
|
192
192
|
stopping.value = false
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const ok = await confirm({
|
|
201
|
-
title: t('inspector.execution.resetConfirm.title'),
|
|
202
|
-
description: t('inspector.execution.resetConfirm.body'),
|
|
203
|
-
variant: 'destructive',
|
|
204
|
-
confirmLabel: t('inspector.execution.resetConfirm.confirm'),
|
|
205
|
-
icon: 'i-lucide-trash-2',
|
|
206
|
-
})
|
|
207
|
-
if (!ok) return
|
|
208
|
-
resetting.value = true
|
|
209
|
-
try {
|
|
210
|
-
await execution.cancel(props.block.id)
|
|
211
|
-
} finally {
|
|
212
|
-
resetting.value = false
|
|
213
|
-
}
|
|
214
|
-
}
|
|
195
|
+
// Destructive: discards the run and returns the block to planned, behind a confirm. Shared with
|
|
196
|
+
// the initiative planning window (which offers the same escape hatch in place) so the two can't
|
|
197
|
+
// drift on the prompt or on what "discard" means.
|
|
198
|
+
const { resetting, resetRun: discardRun } = useRunReset()
|
|
199
|
+
const resetRun = () => discardRun(props.block.id)
|
|
215
200
|
|
|
216
201
|
/**
|
|
217
202
|
* The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
|
|
@@ -76,7 +76,14 @@ export function useBlockDeletion() {
|
|
|
76
76
|
? 'task'
|
|
77
77
|
: block.level === 'module'
|
|
78
78
|
? 'module'
|
|
79
|
-
:
|
|
79
|
+
: // An initiative names itself rather than falling through to the service copy, which
|
|
80
|
+
// would describe a blast radius orders of magnitude larger than the real one. Its
|
|
81
|
+
// cascade is also genuinely different from a container's: the plan goes with it, but
|
|
82
|
+
// the tasks its loop already spawned are NOT descendants — the backend only detaches
|
|
83
|
+
// their membership link — so the count branch below deliberately doesn't apply.
|
|
84
|
+
block.level === 'initiative'
|
|
85
|
+
? 'initiative'
|
|
86
|
+
: 'service'
|
|
80
87
|
const title = t(`panels.inspector.confirmDelete.${kind}.title`)
|
|
81
88
|
// For a container (service/module) state the exact cascade size so the blast radius is
|
|
82
89
|
// explicit — "and everything inside it" hides how many tasks/modules go with it.
|
|
@@ -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,
|