@cat-factory/app 0.138.1 → 0.139.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.
@@ -10,6 +10,7 @@ import { computed } from 'vue'
10
10
  import type { ConsensusContribution, ConsensusSession } from '~/types/consensus'
11
11
  import CopyButton from '~/components/common/CopyButton.vue'
12
12
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
13
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
13
14
  import { agentKindMeta } from '~/utils/catalog'
14
15
 
15
16
  const { t, n } = useI18n()
@@ -30,6 +31,24 @@ const session = computed<ConsensusSession | null>(() =>
30
31
  )
31
32
  const loading = computed(() => (blockId.value ? consensus.isLoading(blockId.value) : false))
32
33
 
34
+ // Composed header: "Consensus · <strategy>" as the dialog title (with the block appended when
35
+ // present), and the agent kind + participant count as the subtitle line.
36
+ const headerTitle = computed(() => {
37
+ const base = session.value
38
+ ? `${t('consensus.titlePrefix')} · ${strategyLabel(session.value.strategy)}`
39
+ : t('consensus.titlePrefix')
40
+ return block.value ? `${base} — ${block.value.title}` : base
41
+ })
42
+ const headerSubtitle = computed(() =>
43
+ session.value
44
+ ? `${agentKindMeta(session.value.agentKind).label} · ${t(
45
+ 'consensus.participantCount',
46
+ { count: session.value.participants.length },
47
+ session.value.participants.length,
48
+ )}`
49
+ : undefined,
50
+ )
51
+
33
52
  // Exhaustive enum→key maps (literal key strings keep the typed-key drift guard live,
34
53
  // vs a runtime-built `consensus.strategy.${value}`).
35
54
  const STRATEGY_LABEL_KEYS: Record<string, string> = {
@@ -94,168 +113,137 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
94
113
  </script>
95
114
 
96
115
  <template>
97
- <Teleport to="body">
98
- <div
99
- v-if="open"
100
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
101
- @click.self="close"
102
- >
103
- <div
104
- class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
105
- role="dialog"
106
- aria-modal="true"
116
+ <ResultWindowShell
117
+ :open="open"
118
+ icon="i-lucide-users-round"
119
+ icon-class="bg-amber-500/15 text-amber-300"
120
+ :title="headerTitle"
121
+ :subtitle="headerSubtitle"
122
+ variant="centered"
123
+ width="5xl"
124
+ @close="close"
125
+ >
126
+ <template v-if="session" #header-extras>
127
+ <span
128
+ class="rounded-full px-2.5 py-1 text-xs font-medium"
129
+ :class="STATUS_CLASS[session.status] ?? 'bg-slate-700 text-slate-300'"
107
130
  >
108
- <!-- header -->
109
- <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
110
- <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/15">
111
- <UIcon name="i-lucide-users-round" class="h-5 w-5 text-amber-300" />
112
- </div>
113
- <div class="min-w-0 flex-1">
114
- <h2 class="truncate text-sm font-semibold text-slate-100">
115
- {{ t('consensus.titlePrefix') }} ·
116
- {{ session ? strategyLabel(session.strategy) : '' }}
117
- <span v-if="block" class="font-normal text-slate-400">— {{ block.title }}</span>
118
- </h2>
119
- <p v-if="session" class="text-xs text-slate-500">
120
- {{ agentKindMeta(session.agentKind).label }} ·
121
- {{
122
- t(
123
- 'consensus.participantCount',
124
- { count: session.participants.length },
125
- session.participants.length,
126
- )
127
- }}
128
- </p>
129
- </div>
130
- <span
131
- v-if="session"
132
- class="rounded-full px-2.5 py-1 text-xs font-medium"
133
- :class="STATUS_CLASS[session.status] ?? 'bg-slate-700 text-slate-300'"
134
- >
135
- {{ statusLabel(session.status) }}
136
- </span>
137
- <button
138
- class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
139
- @click="close"
140
- >
141
- <UIcon name="i-lucide-x" class="h-5 w-5" />
142
- </button>
143
- </header>
131
+ {{ statusLabel(session.status) }}
132
+ </span>
133
+ </template>
144
134
 
145
- <div class="flex-1 overflow-y-auto px-6 py-5">
146
- <div v-if="loading && !session" class="py-16 text-center text-sm text-slate-500">
147
- {{ t('consensus.loading') }}
148
- </div>
149
- <div v-else-if="!session" class="py-16 text-center text-sm text-slate-500">
150
- {{ t('consensus.empty') }}
135
+ <div class="flex-1 overflow-y-auto px-6 py-5">
136
+ <div v-if="loading && !session" class="py-16 text-center text-sm text-slate-500">
137
+ {{ t('consensus.loading') }}
138
+ </div>
139
+ <div v-else-if="!session" class="py-16 text-center text-sm text-slate-500">
140
+ {{ t('consensus.empty') }}
141
+ </div>
142
+ <template v-else>
143
+ <!-- failure -->
144
+ <div
145
+ v-if="session.status === 'failed'"
146
+ class="mb-5 flex items-start gap-2 rounded-lg border border-rose-800/60 bg-rose-950/40 px-4 py-3 text-sm text-rose-200"
147
+ >
148
+ <span class="min-w-0 flex-1">{{
149
+ t('consensus.failed', { error: session.error ?? t('consensus.unknownError') })
150
+ }}</span>
151
+ <CopyButton v-if="session.error" :text="session.error" class="-me-1 shrink-0" />
152
+ </div>
153
+
154
+ <!-- synthesized result -->
155
+ <section v-if="session.synthesis" class="mb-6">
156
+ <div class="mb-2 flex items-center gap-2">
157
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
158
+ {{ t('consensus.synthesizedResult') }}
159
+ </h3>
160
+ <span
161
+ v-if="session.confidence != null"
162
+ class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
163
+ >{{ t('consensus.confidence', { pct: pct(session.confidence) }) }}</span
164
+ >
165
+ <CopyButton :text="session.synthesis" class="ms-auto -my-1" />
151
166
  </div>
152
- <template v-else>
153
- <!-- failure -->
167
+ <MarkdownProse
168
+ :text="session.synthesis"
169
+ class="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
170
+ />
171
+ <ul v-if="session.dissent?.length" class="mt-2 space-y-1">
172
+ <li
173
+ v-for="(d, i) in session.dissent"
174
+ :key="i"
175
+ class="flex items-start gap-2 text-xs text-amber-300/90"
176
+ >
177
+ <UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
178
+ <span>{{ d }}</span>
179
+ </li>
180
+ </ul>
181
+ </section>
182
+
183
+ <!-- participants -->
184
+ <section class="mb-6">
185
+ <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
186
+ {{ t('consensus.panel') }}
187
+ </h3>
188
+ <div class="flex flex-wrap gap-2">
154
189
  <div
155
- v-if="session.status === 'failed'"
156
- class="mb-5 flex items-start gap-2 rounded-lg border border-rose-800/60 bg-rose-950/40 px-4 py-3 text-sm text-rose-200"
190
+ v-for="(p, i) in session.participants"
191
+ :key="p.id"
192
+ class="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-1.5 text-xs"
157
193
  >
158
- <span class="min-w-0 flex-1">{{
159
- t('consensus.failed', { error: session.error ?? t('consensus.unknownError') })
194
+ <span class="font-medium text-slate-200">{{
195
+ t('consensus.expert', { letter: String.fromCharCode(65 + i) })
160
196
  }}</span>
161
- <CopyButton v-if="session.error" :text="session.error" class="-me-1 shrink-0" />
197
+ <span class="text-slate-400"> · {{ p.role }}</span>
198
+ <span v-if="p.modelId" class="ms-1 text-slate-500"
199
+ >({{ models.labelForRef(p.modelId) ?? p.modelId }})</span
200
+ >
162
201
  </div>
202
+ </div>
203
+ </section>
163
204
 
164
- <!-- synthesized result -->
165
- <section v-if="session.synthesis" class="mb-6">
166
- <div class="mb-2 flex items-center gap-2">
167
- <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
168
- {{ t('consensus.synthesizedResult') }}
169
- </h3>
205
+ <!-- rounds -->
206
+ <section v-for="round in session.rounds" :key="round.index" class="mb-5">
207
+ <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
208
+ {{ t('consensus.round.heading', { n: round.index + 1 }) }} ·
209
+ {{ roundLabel(round.kind) }}
210
+ </h3>
211
+ <div class="space-y-3">
212
+ <div
213
+ v-for="c in round.contributions"
214
+ :key="c.participantId"
215
+ class="rounded-lg border border-slate-800 bg-slate-950/40 px-4 py-3"
216
+ >
217
+ <div class="mb-1 flex items-center gap-2">
218
+ <span class="text-xs font-semibold text-slate-200">{{
219
+ anonLabel(c.participantId)
220
+ }}</span>
221
+ <span class="text-xs text-slate-500">{{ roleFor(c.participantId) }}</span>
170
222
  <span
171
- v-if="session.confidence != null"
172
- class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
173
- >{{ t('consensus.confidence', { pct: pct(session.confidence) }) }}</span
174
- >
175
- <CopyButton :text="session.synthesis" class="ms-auto -my-1" />
176
- </div>
177
- <MarkdownProse
178
- :text="session.synthesis"
179
- class="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
180
- />
181
- <ul v-if="session.dissent?.length" class="mt-2 space-y-1">
182
- <li
183
- v-for="(d, i) in session.dissent"
184
- :key="i"
185
- class="flex items-start gap-2 text-xs text-amber-300/90"
186
- >
187
- <UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
188
- <span>{{ d }}</span>
189
- </li>
190
- </ul>
191
- </section>
192
-
193
- <!-- participants -->
194
- <section class="mb-6">
195
- <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
196
- {{ t('consensus.panel') }}
197
- </h3>
198
- <div class="flex flex-wrap gap-2">
199
- <div
200
- v-for="(p, i) in session.participants"
201
- :key="p.id"
202
- class="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-1.5 text-xs"
223
+ v-if="topScore(c)"
224
+ class="ms-auto rounded bg-slate-800 px-1.5 py-0.5 text-xs text-slate-300"
225
+ >{{
226
+ t('consensus.topScore', {
227
+ label: topScore(c)!.label,
228
+ pct: pct(topScore(c)!.value),
229
+ })
230
+ }}</span
203
231
  >
204
- <span class="font-medium text-slate-200">{{
205
- t('consensus.expert', { letter: String.fromCharCode(65 + i) })
206
- }}</span>
207
- <span class="text-slate-400"> · {{ p.role }}</span>
208
- <span v-if="p.modelId" class="ms-1 text-slate-500"
209
- >({{ models.labelForRef(p.modelId) ?? p.modelId }})</span
210
- >
211
- </div>
232
+ <CopyButton :text="c.text" :class="topScore(c) ? '-my-1' : 'ms-auto -my-1'" />
212
233
  </div>
213
- </section>
214
-
215
- <!-- rounds -->
216
- <section v-for="round in session.rounds" :key="round.index" class="mb-5">
217
- <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
218
- {{ t('consensus.round.heading', { n: round.index + 1 }) }} ·
219
- {{ roundLabel(round.kind) }}
220
- </h3>
221
- <div class="space-y-3">
222
- <div
223
- v-for="c in round.contributions"
224
- :key="c.participantId"
225
- class="rounded-lg border border-slate-800 bg-slate-950/40 px-4 py-3"
234
+ <MarkdownProse :text="c.text" class="text-sm text-slate-300" />
235
+ <div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
236
+ <span
237
+ v-for="s in c.scores"
238
+ :key="s.dimension"
239
+ class="rounded bg-slate-800/80 px-1.5 py-0.5 text-xs text-slate-400"
240
+ >{{ s.dimension }}: {{ pct(s.value) }}</span
226
241
  >
227
- <div class="mb-1 flex items-center gap-2">
228
- <span class="text-xs font-semibold text-slate-200">{{
229
- anonLabel(c.participantId)
230
- }}</span>
231
- <span class="text-xs text-slate-500">{{ roleFor(c.participantId) }}</span>
232
- <span
233
- v-if="topScore(c)"
234
- class="ms-auto rounded bg-slate-800 px-1.5 py-0.5 text-xs text-slate-300"
235
- >{{
236
- t('consensus.topScore', {
237
- label: topScore(c)!.label,
238
- pct: pct(topScore(c)!.value),
239
- })
240
- }}</span
241
- >
242
- <CopyButton :text="c.text" :class="topScore(c) ? '-my-1' : 'ms-auto -my-1'" />
243
- </div>
244
- <MarkdownProse :text="c.text" class="text-sm text-slate-300" />
245
- <div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
246
- <span
247
- v-for="s in c.scores"
248
- :key="s.dimension"
249
- class="rounded bg-slate-800/80 px-1.5 py-0.5 text-xs text-slate-400"
250
- >{{ s.dimension }}: {{ pct(s.value) }}</span
251
- >
252
- </div>
253
- </div>
254
242
  </div>
255
- </section>
256
- </template>
257
- </div>
258
- </div>
243
+ </div>
244
+ </div>
245
+ </section>
246
+ </template>
259
247
  </div>
260
- </Teleport>
248
+ </ResultWindowShell>
261
249
  </template>
@@ -8,6 +8,7 @@
8
8
  // `doc-interviewer` step's result view. Live `docInterview` stream events patch the store, so an
9
9
  // open window follows the interview as it progresses. Mirrors InitiativePlanningWindow.vue.
10
10
  import { computed, reactive, watch } from 'vue'
11
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
11
12
 
12
13
  const board = useBoardStore()
13
14
  const docInterview = useDocInterviewStore()
@@ -67,147 +68,118 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
67
68
  </script>
68
69
 
69
70
  <template>
70
- <Teleport to="body">
71
- <div
72
- v-if="open"
73
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
74
- @click.self="close"
75
- >
71
+ <ResultWindowShell
72
+ :open="open"
73
+ icon="i-lucide-messages-square"
74
+ icon-class="bg-indigo-500/15 text-indigo-300"
75
+ :title="block?.title ?? t('docInterview.title')"
76
+ :subtitle="t('docInterview.subtitle')"
77
+ width="3xl"
78
+ testid="doc-interview-window"
79
+ @close="close"
80
+ >
81
+ <template v-if="session" #header-extras>
82
+ <UBadge :color="converged ? 'success' : 'primary'" variant="subtle" size="sm">
83
+ {{ t(converged ? 'docInterview.status.done' : 'docInterview.status.awaiting') }}
84
+ </UBadge>
85
+ </template>
86
+
87
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
88
+ <!-- No session yet -->
76
89
  <div
77
- class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
78
- role="dialog"
79
- aria-modal="true"
80
- data-testid="doc-interview-window"
90
+ v-if="!session"
91
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
81
92
  >
82
- <!-- Header -->
83
- <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
84
- <span
85
- class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
86
- >
87
- <UIcon name="i-lucide-messages-square" class="h-4 w-4" />
88
- </span>
89
- <div class="min-w-0 flex-1">
90
- <h2 class="truncate text-sm font-semibold text-slate-100">
91
- {{ block?.title ?? t('docInterview.title') }}
92
- </h2>
93
- <p class="truncate text-[11px] text-slate-400">
94
- {{ t('docInterview.subtitle') }}
95
- </p>
96
- </div>
97
- <UBadge
98
- v-if="session"
99
- :color="converged ? 'success' : 'primary'"
100
- variant="subtle"
101
- size="sm"
102
- >
103
- {{ t(converged ? 'docInterview.status.done' : 'docInterview.status.awaiting') }}
104
- </UBadge>
105
- <button
106
- class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
107
- @click="close"
108
- >
109
- <UIcon name="i-lucide-x" class="h-4 w-4" />
110
- </button>
111
- </header>
112
-
113
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
114
- <!-- No session yet -->
115
- <div
116
- v-if="!session"
117
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
93
+ <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
94
+ <p class="text-sm">{{ t('docInterview.empty') }}</p>
95
+ </div>
96
+
97
+ <template v-else>
98
+ <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
99
+ {{ t('docInterview.intro') }}
100
+ </p>
101
+
102
+ <!-- Converged: show the synthesized authoring brief -->
103
+ <div
104
+ v-if="converged"
105
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4"
106
+ data-testid="doc-interview-converged"
107
+ >
108
+ <p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-slate-500">
109
+ {{ t('docInterview.brief') }}
110
+ </p>
111
+ <pre
112
+ v-if="session.brief"
113
+ class="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-slate-300"
114
+ >{{ session.brief }}</pre
118
115
  >
119
- <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
120
- <p class="text-sm">{{ t('docInterview.empty') }}</p>
121
- </div>
122
-
123
- <template v-else>
124
- <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
125
- {{ t('docInterview.intro') }}
126
- </p>
127
-
128
- <!-- Converged: show the synthesized authoring brief -->
129
- <div
130
- v-if="converged"
131
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4"
132
- data-testid="doc-interview-converged"
133
- >
134
- <p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-slate-500">
135
- {{ t('docInterview.brief') }}
136
- </p>
137
- <pre
138
- v-if="session.brief"
139
- class="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-slate-300"
140
- >{{ session.brief }}</pre
141
- >
142
- <p v-else class="text-[13px] text-slate-400">{{ t('docInterview.converged') }}</p>
143
- </div>
144
-
145
- <!-- No pending questions but not yet converged -->
146
- <div
147
- v-else-if="questions.length === 0"
148
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
149
- >
150
- {{ t('docInterview.converged') }}
151
- </div>
152
-
153
- <!-- Interview questions -->
154
- <ul v-else class="space-y-4">
155
- <li
156
- v-for="q in questions"
157
- :key="q.key"
158
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
159
- data-testid="doc-interview-question"
160
- >
161
- <p class="mb-2 text-[13px] font-medium text-slate-200">{{ q.question }}</p>
162
- <UTextarea
163
- v-model="drafts[q.key]"
164
- :rows="2"
165
- autoresize
166
- :placeholder="t('docInterview.answerPlaceholder')"
167
- class="w-full"
168
- data-testid="doc-interview-answer"
169
- @blur="persist(q)"
170
- />
171
- </li>
172
- </ul>
173
- </template>
116
+ <p v-else class="text-[13px] text-slate-400">{{ t('docInterview.converged') }}</p>
174
117
  </div>
175
118
 
176
- <!-- Action rail -->
177
- <footer
178
- v-if="session && !converged && questions.length > 0"
179
- class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
119
+ <!-- No pending questions but not yet converged -->
120
+ <div
121
+ v-else-if="questions.length === 0"
122
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
180
123
  >
181
- <p class="text-[11px] text-slate-500">
182
- {{ t('docInterview.hint') }}
183
- </p>
184
- <div class="flex items-center gap-2">
185
- <UButton
186
- color="neutral"
187
- variant="ghost"
188
- size="sm"
189
- :loading="resuming"
190
- :disabled="!access.canExecuteRuns.value"
191
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
192
- data-testid="doc-interview-proceed"
193
- @click="onProceed"
194
- >
195
- {{ t('docInterview.proceed') }}
196
- </UButton>
197
- <UButton
198
- color="primary"
199
- size="sm"
200
- :loading="resuming"
201
- :disabled="!allAnswered || !access.canExecuteRuns.value"
202
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
203
- data-testid="doc-interview-continue"
204
- @click="onContinue"
205
- >
206
- {{ t('docInterview.continue') }}
207
- </UButton>
208
- </div>
209
- </footer>
210
- </div>
124
+ {{ t('docInterview.converged') }}
125
+ </div>
126
+
127
+ <!-- Interview questions -->
128
+ <ul v-else class="space-y-4">
129
+ <li
130
+ v-for="q in questions"
131
+ :key="q.key"
132
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
133
+ data-testid="doc-interview-question"
134
+ >
135
+ <p class="mb-2 text-[13px] font-medium text-slate-200">{{ q.question }}</p>
136
+ <UTextarea
137
+ v-model="drafts[q.key]"
138
+ :rows="2"
139
+ autoresize
140
+ :placeholder="t('docInterview.answerPlaceholder')"
141
+ class="w-full"
142
+ data-testid="doc-interview-answer"
143
+ @blur="persist(q)"
144
+ />
145
+ </li>
146
+ </ul>
147
+ </template>
211
148
  </div>
212
- </Teleport>
149
+
150
+ <!-- Action rail -->
151
+ <footer
152
+ v-if="session && !converged && questions.length > 0"
153
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
154
+ >
155
+ <p class="text-[11px] text-slate-500">
156
+ {{ t('docInterview.hint') }}
157
+ </p>
158
+ <div class="flex items-center gap-2">
159
+ <UButton
160
+ color="neutral"
161
+ variant="ghost"
162
+ size="sm"
163
+ :loading="resuming"
164
+ :disabled="!access.canExecuteRuns.value"
165
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
166
+ data-testid="doc-interview-proceed"
167
+ @click="onProceed"
168
+ >
169
+ {{ t('docInterview.proceed') }}
170
+ </UButton>
171
+ <UButton
172
+ color="primary"
173
+ size="sm"
174
+ :loading="resuming"
175
+ :disabled="!allAnswered || !access.canExecuteRuns.value"
176
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
177
+ data-testid="doc-interview-continue"
178
+ @click="onContinue"
179
+ >
180
+ {{ t('docInterview.continue') }}
181
+ </UButton>
182
+ </div>
183
+ </footer>
184
+ </ResultWindowShell>
213
185
  </template>
@@ -21,11 +21,9 @@ const access = useWorkspaceAccess()
21
21
 
22
22
  const { t } = useI18n()
23
23
 
24
- // `manageEscape: false` — `ResultWindowShell` owns Escape (and focus trap + scroll lock +
25
- // stacking) via the shared overlay behaviour.
26
- const { open, blockId, instanceId, stepIndex, close } = useResultView('follow-ups', {
27
- manageEscape: false,
28
- })
24
+ // `ResultWindowShell` owns Escape (and focus trap + scroll lock + stacking) via the shared
25
+ // overlay behaviour.
26
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('follow-ups')
29
27
 
30
28
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
31
29
  const headerTitle = computed(() =>
@@ -24,11 +24,9 @@ const access = useWorkspaceAccess()
24
24
  const { t } = useI18n()
25
25
 
26
26
  // Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
27
- // `manageEscape: false` `ResultWindowShell` owns Escape (and focus trap + scroll lock +
28
- // stacking). No `stepRef`: this is a pre-run decision, so there's no "restart from here".
27
+ // No `stepRef`: this is a pre-run decision, so there's no "restart from here".
29
28
  const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
30
29
  onOpen: (id) => void forkDecision.load(id),
31
- manageEscape: false,
32
30
  })
33
31
 
34
32
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
@@ -20,11 +20,9 @@ const { t } = useI18n()
20
20
  const access = useWorkspaceAccess()
21
21
 
22
22
  // Synchronous window: it reads its state straight off the execution step, so there's
23
- // nothing to fetch on open (no `onOpen` loader). `manageEscape: false` `ResultWindowShell`
24
- // owns Escape (and focus trap + scroll lock + stacking).
25
- const { open, blockId, instanceId, stepIndex, close } = useResultView('gate', {
26
- manageEscape: false,
27
- })
23
+ // nothing to fetch on open (no `onOpen` loader). `ResultWindowShell` owns Escape (and focus
24
+ // trap + scroll lock + stacking).
25
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('gate')
28
26
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
29
27
  const prUrl = computed(() => block.value?.pullRequest?.url ?? null)
30
28
  const headerTitle = computed(
@@ -25,11 +25,9 @@ const access = useWorkspaceAccess()
25
25
  const { confirmAction, toastDone } = useConfirmAction()
26
26
 
27
27
  // Shared seam contract (open/blockId/close). No `onOpen` loader: the gate state rides on the
28
- // execution step, pushed over the stream. `manageEscape: false` `ResultWindowShell` owns
29
- // Escape (and focus trap + scroll lock + stacking).
30
- const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test', {
31
- manageEscape: false,
32
- })
28
+ // execution step, pushed over the stream. `ResultWindowShell` owns Escape (and focus trap +
29
+ // scroll lock + stacking).
30
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test')
33
31
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
34
32
  const headerTitle = computed(() =>
35
33
  block.value ? t('humanTest.titleWithBlock', { title: block.value.title }) : t('humanTest.title'),