@cat-factory/app 0.147.2 → 0.147.3
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/app/components/prReview/PrReviewPhaseBadge.vue +2 -2
- package/app/components/prReview/PrReviewWindow.vue +19 -15
- package/app/stores/personalSubscriptions.spec.ts +57 -6
- package/app/stores/personalSubscriptions.ts +72 -11
- package/app/utils/prReviewProgress.spec.ts +17 -15
- package/app/utils/prReviewProgress.ts +26 -15
- package/i18n/locales/de.json +4 -4
- package/i18n/locales/en.json +4 -4
- package/i18n/locales/es.json +4 -4
- package/i18n/locales/fr.json +4 -4
- package/i18n/locales/he.json +4 -4
- package/i18n/locales/it.json +4 -4
- package/i18n/locales/ja.json +4 -4
- package/i18n/locales/pl.json +4 -4
- package/i18n/locales/tr.json +4 -4
- package/i18n/locales/uk.json +4 -4
- package/package.json +1 -1
|
@@ -23,7 +23,7 @@ const phase = computed(() => prReviewPhase(props.step.prReview, props.step.subta
|
|
|
23
23
|
// working (suppressed on a failed run). `awaiting` is the parked "findings ready" state — a
|
|
24
24
|
// steady amber prompt, not a spinner.
|
|
25
25
|
const PHASE_META: Record<PrReviewPhaseKind, { icon: string; spin: boolean; class: string }> = {
|
|
26
|
-
|
|
26
|
+
planning: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
|
|
27
27
|
reviewing: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
|
|
28
28
|
awaiting: { icon: 'i-lucide-clipboard-check', spin: false, class: 'text-amber-300' },
|
|
29
29
|
challenging: { icon: 'i-lucide-gavel', spin: true, class: 'text-indigo-300' },
|
|
@@ -35,7 +35,7 @@ const PHASE_META: Record<PrReviewPhaseKind, { icon: string; spin: boolean; class
|
|
|
35
35
|
// label fails the typecheck (the sanctioned dynamic enum→key guard — tier 1 can't see a
|
|
36
36
|
// runtime-built key), matching the `CHUNK_STATUS_KEY` pattern in PrReviewWindow.vue.
|
|
37
37
|
const PHASE_LABEL_KEY: Record<PrReviewPhaseKind, string> = {
|
|
38
|
-
|
|
38
|
+
planning: 'prReview.phase.planning',
|
|
39
39
|
reviewing: 'prReview.phase.reviewing',
|
|
40
40
|
awaiting: 'prReview.phase.awaiting',
|
|
41
41
|
challenging: 'prReview.phase.challenging',
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
StepSubtasks,
|
|
22
22
|
} from '~/types/execution'
|
|
23
23
|
import { subtaskIconClass } from '~/utils/pipelineRender'
|
|
24
|
-
import { activeChunkLabels, chunkReviewPercent,
|
|
24
|
+
import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
|
|
25
25
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
26
26
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
27
27
|
|
|
@@ -55,15 +55,18 @@ const awaiting = computed(() => status.value === 'awaiting_selection')
|
|
|
55
55
|
const challenging = computed(() => status.value === 'challenging')
|
|
56
56
|
// The reviewer's live todo list while it works, streamed onto the step. Its entries are the
|
|
57
57
|
// cohesive slices/chunks the agent grouped the diff into (plus a final "aggregate" step). The
|
|
58
|
-
// derivation of the
|
|
59
|
-
// (pure + unit-tested); see that module for the
|
|
58
|
+
// derivation of the `reviewing`-phase sub-states from it lives in `~/utils/prReviewProgress`
|
|
59
|
+
// (pure + unit-tested); see that module for the has-a-plan signal.
|
|
60
60
|
const subtasks = computed<StepSubtasks | null>(() => step.value?.subtasks ?? null)
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* Planning sub-phase: no per-slice todo plan has been reported yet. This is NEUTRAL — the
|
|
64
|
+
* reviewer may still be grouping the diff, OR it may be reviewing via parallel subagents that
|
|
65
|
+
* never write a parent todo plan (ADR 0026 D2.2). Either way we must NOT claim a specific
|
|
66
|
+
* "slicing" phase; we show a neutral "reviewing…" state and switch to the per-slice list the
|
|
67
|
+
* moment a plan exists.
|
|
65
68
|
*/
|
|
66
|
-
const
|
|
69
|
+
const planning = computed(() => status.value === 'reviewing' && hasNoSlicePlan(subtasks.value))
|
|
67
70
|
|
|
68
71
|
/** Chunk-review completion, clamped 0..100 for the progress bar. */
|
|
69
72
|
const chunkPercent = computed(() => chunkReviewPercent(subtasks.value))
|
|
@@ -256,28 +259,29 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
256
259
|
|
|
257
260
|
<div class="flex min-h-0 flex-1">
|
|
258
261
|
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
259
|
-
<!-- Reviewing: the read-only reviewer is still working.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
<!-- Reviewing: the read-only reviewer is still working. Two sub-states, told apart by
|
|
263
|
+
whether a per-slice todo plan exists — a NEUTRAL "planning" state (no plan reported yet;
|
|
264
|
+
may be grouping OR reviewing via subagents that write no parent plan) vs the per-slice
|
|
265
|
+
list. The copy never asserts a specific "slicing" phase from an empty todo list. -->
|
|
262
266
|
<div
|
|
263
267
|
v-if="status === 'reviewing'"
|
|
264
268
|
data-testid="pr-review-reviewing"
|
|
265
269
|
class="flex h-full flex-col"
|
|
266
270
|
>
|
|
267
|
-
<!--
|
|
271
|
+
<!-- PLANNING: no per-slice plan yet — a neutral "reviewing…" state, not a "slicing" claim. -->
|
|
268
272
|
<div
|
|
269
|
-
v-if="
|
|
270
|
-
data-testid="pr-review-
|
|
273
|
+
v-if="planning"
|
|
274
|
+
data-testid="pr-review-planning"
|
|
271
275
|
class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
272
276
|
>
|
|
273
277
|
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
274
|
-
<p class="text-sm text-slate-200">{{ t('prReview.reviewing.
|
|
278
|
+
<p class="text-sm text-slate-200">{{ t('prReview.reviewing.planning.title') }}</p>
|
|
275
279
|
<p class="max-w-sm text-[11px] text-slate-500">
|
|
276
|
-
{{ t('prReview.reviewing.
|
|
280
|
+
{{ t('prReview.reviewing.planning.hint') }}
|
|
277
281
|
</p>
|
|
278
282
|
</div>
|
|
279
283
|
|
|
280
|
-
<!-- REVIEWING:
|
|
284
|
+
<!-- REVIEWING: a per-slice plan exists — show every slice with its status + which are active now. -->
|
|
281
285
|
<div v-else data-testid="pr-review-reviewing-chunks" class="py-2">
|
|
282
286
|
<div class="mb-1 flex items-center gap-2 text-sm text-slate-200">
|
|
283
287
|
<UIcon
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
2
|
import { ApiError } from '~/composables/api/errors'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
personalPasswordCacheKey,
|
|
5
|
+
usePersonalSubscriptionsStore,
|
|
6
|
+
} from '~/stores/personalSubscriptions'
|
|
4
7
|
|
|
5
|
-
// The
|
|
6
|
-
//
|
|
7
|
-
const CACHE_KEY = '
|
|
8
|
+
// The scoped localStorage key the store caches under, for the stubbed apiBase ('') + user
|
|
9
|
+
// (null → 'anon') the default test setup provides (see test/setup.ts). ADR 0026 D7.
|
|
10
|
+
const CACHE_KEY = personalPasswordCacheKey('', null)
|
|
11
|
+
/** The retired pre-scoping global key. */
|
|
12
|
+
const LEGACY_CACHE_KEY = 'cf.personal-pw'
|
|
8
13
|
const HOUR = 60 * 60 * 1000
|
|
9
14
|
/** Mirrors PASSWORD_EXPIRY_BUFFER_MS in the store — the runway a key must have to be ridden. */
|
|
10
15
|
const BUFFER_MS = 8 * HOUR
|
|
11
16
|
|
|
12
17
|
/** Write a cache entry with an explicit remaining lifetime (positive = valid, negative = past). */
|
|
13
|
-
function seedCache(password: string, msFromNow: number) {
|
|
14
|
-
localStorage.setItem(
|
|
18
|
+
function seedCache(password: string, msFromNow: number, key = CACHE_KEY) {
|
|
19
|
+
localStorage.setItem(key, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** A 428 credential_required error shaped like the server envelope the store parses. */
|
|
@@ -61,6 +66,52 @@ describe('personal-password cache expiry buffer', () => {
|
|
|
61
66
|
})
|
|
62
67
|
})
|
|
63
68
|
|
|
69
|
+
// ADR 0026 D7: the cache is scoped per installation (apiBase) + user, and the retired global
|
|
70
|
+
// `cf.personal-pw` key is purged on sight and never reused.
|
|
71
|
+
describe('per-installation + per-user cache scoping', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
localStorage.clear()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('derives distinct keys per installation and per user', () => {
|
|
77
|
+
// Same origin, different installations (apiBase) → different keys.
|
|
78
|
+
expect(personalPasswordCacheKey('https://a.example', 'usr_1')).not.toBe(
|
|
79
|
+
personalPasswordCacheKey('https://b.example', 'usr_1'),
|
|
80
|
+
)
|
|
81
|
+
// Same installation, different users → different keys.
|
|
82
|
+
expect(personalPasswordCacheKey('https://a.example', 'usr_1')).not.toBe(
|
|
83
|
+
personalPasswordCacheKey('https://a.example', 'usr_2'),
|
|
84
|
+
)
|
|
85
|
+
// A missing user id collapses to a stable `anon` segment (auth disabled).
|
|
86
|
+
expect(personalPasswordCacheKey('https://a.example', null)).toBe(
|
|
87
|
+
personalPasswordCacheKey('https://a.example', undefined),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does NOT read another installation/user cache entry', () => {
|
|
92
|
+
const store = usePersonalSubscriptionsStore()
|
|
93
|
+
// A healthy entry belonging to a DIFFERENT scope must be invisible to this store.
|
|
94
|
+
seedCache('other-secret', 40 * HOUR, personalPasswordCacheKey('https://other', 'usr_x'))
|
|
95
|
+
expect(store.getCachedPassword()).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('purges the retired global `cf.personal-pw` key on read and never reuses it', () => {
|
|
99
|
+
const store = usePersonalSubscriptionsStore()
|
|
100
|
+
seedCache('legacy-secret', 40 * HOUR, LEGACY_CACHE_KEY)
|
|
101
|
+
// The legacy value is not offered (the scoped key is absent)…
|
|
102
|
+
expect(store.getCachedPassword()).toBeUndefined()
|
|
103
|
+
// …and the retired global key is removed so it can't be reused across installs/users.
|
|
104
|
+
expect(localStorage.getItem(LEGACY_CACHE_KEY)).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('round-trips a password under the scoped key', () => {
|
|
108
|
+
const store = usePersonalSubscriptionsStore()
|
|
109
|
+
seedCache('mine', 40 * HOUR)
|
|
110
|
+
expect(store.getCachedPassword()).toBe('mine')
|
|
111
|
+
expect(localStorage.getItem(CACHE_KEY)).not.toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
64
115
|
describe('withCredential early re-entry', () => {
|
|
65
116
|
beforeEach(() => {
|
|
66
117
|
localStorage.clear()
|
|
@@ -12,12 +12,22 @@ import type {
|
|
|
12
12
|
// a personal PASSWORD. The token itself is double-encrypted server-side and never returned;
|
|
13
13
|
// this store only carries metadata + the renewal warning.
|
|
14
14
|
//
|
|
15
|
-
// To keep the password mostly transparent, it is cached CLIENT-SIDE in localStorage
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
15
|
+
// To keep the password mostly transparent, it is cached CLIENT-SIDE in localStorage with a
|
|
16
|
+
// TTL: the backend gate applies one password to ALL of a run's individual-usage vendors, so
|
|
17
|
+
// there's no point keying the cache per vendor. A task start/retry (and each interaction with a
|
|
18
|
+
// live run) rides along with the cached password — sent as the `X-Personal-Password` header —
|
|
19
|
+
// and the user is only re-prompted once it expires (or is wrong).
|
|
20
|
+
//
|
|
21
|
+
// The cache key is scoped PER INSTALLATION and PER USER (ADR 0026 D7): `cf.personal-pw:<hash of
|
|
22
|
+
// the configured API base>:<user id>`. The bare `cf.personal-pw` it replaced was keyed only by
|
|
23
|
+
// browser origin, so on a shared origin (two local installs on localhost, or one hosted origin
|
|
24
|
+
// fronting several deployments) one installation's cached password was offered to another as
|
|
25
|
+
// `X-Personal-Password`, and a second signed-in user on a shared browser profile inherited the
|
|
26
|
+
// first user's password. Keying on the API base (distinct per installation even when the origin
|
|
27
|
+
// is shared) and the user id closes both: another installation or user sees no cache and is
|
|
28
|
+
// challenged normally. Per-workspace scoping is NOT added — the password is a per-user secret
|
|
29
|
+
// (the backend applies one to all of a run's individual-usage vendors), so it would only add
|
|
30
|
+
// redundant prompts.
|
|
21
31
|
//
|
|
22
32
|
// Every gated action (start / retry / confirm) re-validates the cache against an 8h EXPIRY
|
|
23
33
|
// BUFFER: a key with less than that runway left is withheld (treated as absent) so the
|
|
@@ -43,7 +53,37 @@ const PASSWORD_TTL_MS = 40 * 60 * 60 * 1000
|
|
|
43
53
|
* EARLY — a buffer wide enough that a pipeline kicked off now won't outlive the key (8h).
|
|
44
54
|
*/
|
|
45
55
|
const PASSWORD_EXPIRY_BUFFER_MS = 8 * 60 * 60 * 1000
|
|
46
|
-
|
|
56
|
+
|
|
57
|
+
/** Prefix for the per-installation + per-user cache key (see the file header). */
|
|
58
|
+
const CACHE_KEY_PREFIX = 'cf.personal-pw'
|
|
59
|
+
/**
|
|
60
|
+
* The pre-scoping GLOBAL key (origin-only), retired by ADR 0026 D7. It is purged on sight and
|
|
61
|
+
* its value is NEVER reused — migrating it would re-introduce exactly the cross-installation /
|
|
62
|
+
* cross-user reuse the scoping removes, so the affected user is simply re-challenged once.
|
|
63
|
+
*/
|
|
64
|
+
const LEGACY_CACHE_KEY = 'cf.personal-pw'
|
|
65
|
+
|
|
66
|
+
/** A short, stable non-cryptographic hash (FNV-1a → base36) of the API base, for the key scope. */
|
|
67
|
+
function scopeHash(input: string): string {
|
|
68
|
+
let h = 0x811c9dc5
|
|
69
|
+
for (let i = 0; i < input.length; i++) {
|
|
70
|
+
h ^= input.charCodeAt(i)
|
|
71
|
+
h = Math.imul(h, 0x01000193)
|
|
72
|
+
}
|
|
73
|
+
return (h >>> 0).toString(36)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The localStorage key the personal password is cached under, scoped per installation (the
|
|
78
|
+
* configured `apiBase`, hashed) and per user (`userId`, or `anon` when auth is off / nobody is
|
|
79
|
+
* signed in). Exported for the store's unit test to seed/read the exact key the store uses.
|
|
80
|
+
*/
|
|
81
|
+
export function personalPasswordCacheKey(
|
|
82
|
+
apiBase: string,
|
|
83
|
+
userId: string | null | undefined,
|
|
84
|
+
): string {
|
|
85
|
+
return `${CACHE_KEY_PREFIX}:${scopeHash(apiBase)}:${userId ?? 'anon'}`
|
|
86
|
+
}
|
|
47
87
|
|
|
48
88
|
/** A credential prompt the UI must satisfy (set when the server replies 428). */
|
|
49
89
|
export interface PendingCredential {
|
|
@@ -71,6 +111,14 @@ export function parseCredentialError(
|
|
|
71
111
|
|
|
72
112
|
export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions', () => {
|
|
73
113
|
const api = useApi()
|
|
114
|
+
const auth = useAuthStore()
|
|
115
|
+
// The configured API base is a static per-installation fact, so hash it once at setup; the user
|
|
116
|
+
// id is dynamic, so it is read lazily off the auth store when the key is computed.
|
|
117
|
+
const apiBase = String(useRuntimeConfig().public.apiBase ?? '')
|
|
118
|
+
/** The current per-installation + per-user cache key. */
|
|
119
|
+
function cacheKey(): string {
|
|
120
|
+
return personalPasswordCacheKey(apiBase, auth.user?.id)
|
|
121
|
+
}
|
|
74
122
|
const subscriptions = ref<PersonalSubscriptionStatus[]>([])
|
|
75
123
|
const loading = ref(false)
|
|
76
124
|
/** When set, a credential modal should open to satisfy the pending prompt. */
|
|
@@ -119,12 +167,15 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
119
167
|
*/
|
|
120
168
|
function getCachedPassword(bufferMs = 0): string | undefined {
|
|
121
169
|
if (typeof localStorage === 'undefined') return undefined
|
|
170
|
+
// Retire the pre-scoping global key on sight (never reused — see LEGACY_CACHE_KEY).
|
|
171
|
+
purgeLegacyCache()
|
|
122
172
|
try {
|
|
123
|
-
const
|
|
173
|
+
const key = cacheKey()
|
|
174
|
+
const raw = localStorage.getItem(key)
|
|
124
175
|
if (!raw) return undefined
|
|
125
176
|
const { password, expiresAt } = JSON.parse(raw) as { password: string; expiresAt: number }
|
|
126
177
|
if (Date.now() > expiresAt) {
|
|
127
|
-
localStorage.removeItem(
|
|
178
|
+
localStorage.removeItem(key)
|
|
128
179
|
return undefined
|
|
129
180
|
}
|
|
130
181
|
// Within the buffer the key is still valid — keep it, but withhold it so the action
|
|
@@ -140,7 +191,7 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
140
191
|
if (typeof localStorage === 'undefined') return
|
|
141
192
|
try {
|
|
142
193
|
localStorage.setItem(
|
|
143
|
-
|
|
194
|
+
cacheKey(),
|
|
144
195
|
JSON.stringify({ password, expiresAt: Date.now() + PASSWORD_TTL_MS }),
|
|
145
196
|
)
|
|
146
197
|
} catch {
|
|
@@ -151,7 +202,17 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
151
202
|
function clearCachedPassword() {
|
|
152
203
|
if (typeof localStorage === 'undefined') return
|
|
153
204
|
try {
|
|
154
|
-
localStorage.removeItem(
|
|
205
|
+
localStorage.removeItem(cacheKey())
|
|
206
|
+
} catch {
|
|
207
|
+
// best-effort
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Remove the retired global `cf.personal-pw` key so its value can never be offered again. */
|
|
212
|
+
function purgeLegacyCache() {
|
|
213
|
+
if (typeof localStorage === 'undefined') return
|
|
214
|
+
try {
|
|
215
|
+
localStorage.removeItem(LEGACY_CACHE_KEY)
|
|
155
216
|
} catch {
|
|
156
217
|
// best-effort
|
|
157
218
|
}
|
|
@@ -3,7 +3,7 @@ import type { PrReviewStepState, StepSubtasks } from '~/types/execution'
|
|
|
3
3
|
import {
|
|
4
4
|
activeChunkLabels,
|
|
5
5
|
chunkReviewPercent,
|
|
6
|
-
|
|
6
|
+
hasNoSlicePlan,
|
|
7
7
|
prReviewPhase,
|
|
8
8
|
} from './prReviewProgress'
|
|
9
9
|
|
|
@@ -14,18 +14,19 @@ const subtasks = (over: Partial<StepSubtasks>): StepSubtasks => ({
|
|
|
14
14
|
...over,
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
describe('
|
|
18
|
-
it('
|
|
19
|
-
// No plan
|
|
20
|
-
|
|
21
|
-
expect(
|
|
22
|
-
expect(
|
|
17
|
+
describe('hasNoSlicePlan', () => {
|
|
18
|
+
it('has no plan when there is no todo list yet (null/undefined or empty)', () => {
|
|
19
|
+
// No per-slice plan reported — a NEUTRAL signal (grouping, OR a subagent review that never
|
|
20
|
+
// writes a parent plan), NOT proof of "slicing".
|
|
21
|
+
expect(hasNoSlicePlan(null)).toBe(true)
|
|
22
|
+
expect(hasNoSlicePlan(undefined)).toBe(true)
|
|
23
|
+
expect(hasNoSlicePlan(subtasks({ total: 0 }))).toBe(true)
|
|
23
24
|
})
|
|
24
25
|
|
|
25
|
-
it('
|
|
26
|
-
expect(
|
|
27
|
-
// A single-
|
|
28
|
-
expect(
|
|
26
|
+
it('has a plan once the todo list exists', () => {
|
|
27
|
+
expect(hasNoSlicePlan(subtasks({ total: 3, completed: 1 }))).toBe(false)
|
|
28
|
+
// A single-slice plan still counts — a plan of size 1 is a committed plan.
|
|
29
|
+
expect(hasNoSlicePlan(subtasks({ total: 1 }))).toBe(false)
|
|
29
30
|
})
|
|
30
31
|
})
|
|
31
32
|
|
|
@@ -89,15 +90,16 @@ describe('prReviewPhase', () => {
|
|
|
89
90
|
expect(prReviewPhase(state('skipped'), null)).toBeNull()
|
|
90
91
|
})
|
|
91
92
|
|
|
92
|
-
it('is
|
|
93
|
-
// No plan
|
|
93
|
+
it('is the neutral `planning` kind while reviewing with no todo list yet (counts zeroed)', () => {
|
|
94
|
+
// No plan reported → surface a neutral "reviewing, planning" phase (NOT "slicing") and don't
|
|
95
|
+
// leak a misleading 0/0 slice count.
|
|
94
96
|
expect(prReviewPhase(state('reviewing'), null)).toEqual({
|
|
95
|
-
kind: '
|
|
97
|
+
kind: 'planning',
|
|
96
98
|
completed: 0,
|
|
97
99
|
total: 0,
|
|
98
100
|
})
|
|
99
101
|
expect(prReviewPhase(state('reviewing'), subtasks({ total: 0 }))).toEqual({
|
|
100
|
-
kind: '
|
|
102
|
+
kind: 'planning',
|
|
101
103
|
completed: 0,
|
|
102
104
|
total: 0,
|
|
103
105
|
})
|
|
@@ -3,14 +3,21 @@ import type { PrReviewStepState, StepSubtasks } from '~/types/execution'
|
|
|
3
3
|
// Pure derivation of the PR deep-reviewer's `reviewing`-phase progress, factored out of
|
|
4
4
|
// `PrReviewWindow.vue` so it is unit-testable independently of the Vue component.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// When the reviewer maintains a per-slice todo list (`step.subtasks`) — one entry per cohesive
|
|
7
|
+
// chunk of the diff — the UI shows per-slice progress. But that list is NOT always present: the
|
|
8
|
+
// reviewer often reviews via parallel general-purpose subagents, which never write a parent-level
|
|
9
|
+
// TodoWrite plan, so `subtasks` stays empty for the whole review (ADR 0026 P2/D2.2). So an empty
|
|
10
|
+
// todo list is NOT proof the reviewer is "still slicing" — it only means no per-slice plan has
|
|
11
|
+
// been reported yet. We therefore surface a NEUTRAL "reviewing, planning slices" state and switch
|
|
12
|
+
// to per-slice status the moment a plan exists — the UI never asserts a specific "slicing" phase
|
|
13
|
+
// purely because the parent stream emitted no todo list.
|
|
11
14
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* True while NO per-slice todo plan has been reported (`total <= 0`). This is a neutral
|
|
17
|
+
* "no plan yet" signal — the reviewer may be grouping the diff OR reviewing via subagents that
|
|
18
|
+
* don't write a parent plan — NOT an assertion that it is "still slicing" (see the note above).
|
|
19
|
+
*/
|
|
20
|
+
export function hasNoSlicePlan(subtasks: StepSubtasks | null | undefined): boolean {
|
|
14
21
|
return (subtasks?.total ?? 0) <= 0
|
|
15
22
|
}
|
|
16
23
|
|
|
@@ -29,13 +36,16 @@ export function activeChunkLabels(subtasks: StepSubtasks | null | undefined): st
|
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* The at-a-glance phase of a `pr-reviewer` step, collapsing its `prReview.status` (+ the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* has-a-plan signal from the todo list) into a single kind the board surfaces label without
|
|
40
|
+
* re-deriving. `completed`/`total` carry the slice counts for the `reviewing` kind. The
|
|
41
|
+
* `planning` kind is the NEUTRAL "reviewing, no per-slice plan reported yet" state (see
|
|
42
|
+
* {@link hasNoSlicePlan}) — it deliberately does NOT claim a specific "slicing" phase, since an
|
|
43
|
+
* empty todo list is the normal shape of a subagent-driven review, not proof of slicing.
|
|
34
44
|
* Returns `null` for the terminal / passed-through states (`done`/`skipped`) and when there is
|
|
35
45
|
* no live review — those have no in-flight phase to show.
|
|
36
46
|
*/
|
|
37
47
|
export type PrReviewPhaseKind =
|
|
38
|
-
| '
|
|
48
|
+
| 'planning'
|
|
39
49
|
| 'reviewing'
|
|
40
50
|
| 'awaiting'
|
|
41
51
|
| 'challenging'
|
|
@@ -44,9 +54,9 @@ export type PrReviewPhaseKind =
|
|
|
44
54
|
|
|
45
55
|
export interface PrReviewPhase {
|
|
46
56
|
kind: PrReviewPhaseKind
|
|
47
|
-
/** Slices whose review is finished (0 while
|
|
57
|
+
/** Slices whose review is finished (0 while planning). */
|
|
48
58
|
completed: number
|
|
49
|
-
/** Total slices the reviewer grouped the diff into (0 while
|
|
59
|
+
/** Total slices the reviewer grouped the diff into (0 while planning). */
|
|
50
60
|
total: number
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -60,9 +70,10 @@ export function prReviewPhase(
|
|
|
60
70
|
const total = subtasks?.total ?? 0
|
|
61
71
|
switch (status) {
|
|
62
72
|
case 'reviewing':
|
|
63
|
-
// No
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
// No per-slice plan reported yet ⇒ neutral "planning" (don't claim "slicing"); once a plan
|
|
74
|
+
// exists, work through the chunks with live counts.
|
|
75
|
+
return hasNoSlicePlan(subtasks)
|
|
76
|
+
? { kind: 'planning', completed: 0, total: 0 }
|
|
66
77
|
: { kind: 'reviewing', completed, total }
|
|
67
78
|
case 'awaiting_selection':
|
|
68
79
|
return { kind: 'awaiting', completed, total }
|
package/i18n/locales/de.json
CHANGED
|
@@ -4943,9 +4943,9 @@
|
|
|
4943
4943
|
"suggestedFix": "Lösungsvorschlag:",
|
|
4944
4944
|
"line": "Zeile {line}",
|
|
4945
4945
|
"reviewing": {
|
|
4946
|
-
"
|
|
4947
|
-
"title": "
|
|
4948
|
-
"hint": "
|
|
4946
|
+
"planning": {
|
|
4947
|
+
"title": "Pull Request wird geprüft…",
|
|
4948
|
+
"hint": "Der Prüfer arbeitet den Diff durch. Der Fortschritt pro Abschnitt erscheint hier, sobald die Änderungen in Abschnitte gruppiert werden."
|
|
4949
4949
|
},
|
|
4950
4950
|
"reviewingChunks": {
|
|
4951
4951
|
"title": "Abschnitte werden geprüft…",
|
|
@@ -4962,7 +4962,7 @@
|
|
|
4962
4962
|
}
|
|
4963
4963
|
},
|
|
4964
4964
|
"phase": {
|
|
4965
|
-
"
|
|
4965
|
+
"planning": "Wird geprüft…",
|
|
4966
4966
|
"reviewing": "Prüfe {completed}/{total} Abschnitte",
|
|
4967
4967
|
"awaiting": "Ergebnisse bereit",
|
|
4968
4968
|
"challenging": "Wird untersucht…",
|
package/i18n/locales/en.json
CHANGED
|
@@ -5072,9 +5072,9 @@
|
|
|
5072
5072
|
"suggestedFix": "Suggested fix:",
|
|
5073
5073
|
"line": "line {line}",
|
|
5074
5074
|
"reviewing": {
|
|
5075
|
-
"
|
|
5076
|
-
"title": "
|
|
5077
|
-
"hint": "
|
|
5075
|
+
"planning": {
|
|
5076
|
+
"title": "Reviewing the pull request…",
|
|
5077
|
+
"hint": "The reviewer is working through the diff. Per-slice progress appears here once it groups the changes into slices."
|
|
5078
5078
|
},
|
|
5079
5079
|
"reviewingChunks": {
|
|
5080
5080
|
"title": "Reviewing the chunks…",
|
|
@@ -5091,7 +5091,7 @@
|
|
|
5091
5091
|
}
|
|
5092
5092
|
},
|
|
5093
5093
|
"phase": {
|
|
5094
|
-
"
|
|
5094
|
+
"planning": "Reviewing…",
|
|
5095
5095
|
"reviewing": "Reviewing {completed}/{total} slices",
|
|
5096
5096
|
"awaiting": "Findings ready",
|
|
5097
5097
|
"challenging": "Investigating…",
|
package/i18n/locales/es.json
CHANGED
|
@@ -4931,9 +4931,9 @@
|
|
|
4931
4931
|
"suggestedFix": "Corrección sugerida:",
|
|
4932
4932
|
"line": "línea {line}",
|
|
4933
4933
|
"reviewing": {
|
|
4934
|
-
"
|
|
4935
|
-
"title": "
|
|
4936
|
-
"hint": "
|
|
4934
|
+
"planning": {
|
|
4935
|
+
"title": "Revisando la pull request…",
|
|
4936
|
+
"hint": "El revisor está analizando el diff. El progreso por sección aparecerá aquí cuando agrupe los cambios en secciones."
|
|
4937
4937
|
},
|
|
4938
4938
|
"reviewingChunks": {
|
|
4939
4939
|
"title": "Revisando los fragmentos…",
|
|
@@ -4950,7 +4950,7 @@
|
|
|
4950
4950
|
}
|
|
4951
4951
|
},
|
|
4952
4952
|
"phase": {
|
|
4953
|
-
"
|
|
4953
|
+
"planning": "Revisando…",
|
|
4954
4954
|
"reviewing": "Revisando {completed}/{total} secciones",
|
|
4955
4955
|
"awaiting": "Resultados listos",
|
|
4956
4956
|
"challenging": "Investigando…",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -4931,9 +4931,9 @@
|
|
|
4931
4931
|
"suggestedFix": "Correction suggérée :",
|
|
4932
4932
|
"line": "ligne {line}",
|
|
4933
4933
|
"reviewing": {
|
|
4934
|
-
"
|
|
4935
|
-
"title": "
|
|
4936
|
-
"hint": "
|
|
4934
|
+
"planning": {
|
|
4935
|
+
"title": "Révision de la pull request…",
|
|
4936
|
+
"hint": "Le relecteur parcourt le diff. La progression par section apparaîtra ici une fois les modifications regroupées en sections."
|
|
4937
4937
|
},
|
|
4938
4938
|
"reviewingChunks": {
|
|
4939
4939
|
"title": "Revue des sections…",
|
|
@@ -4950,7 +4950,7 @@
|
|
|
4950
4950
|
}
|
|
4951
4951
|
},
|
|
4952
4952
|
"phase": {
|
|
4953
|
-
"
|
|
4953
|
+
"planning": "Révision…",
|
|
4954
4954
|
"reviewing": "Révision {completed}/{total} sections",
|
|
4955
4955
|
"awaiting": "Résultats prêts",
|
|
4956
4956
|
"challenging": "Analyse en cours…",
|
package/i18n/locales/he.json
CHANGED
|
@@ -4942,9 +4942,9 @@
|
|
|
4942
4942
|
"suggestedFix": "תיקון מוצע:",
|
|
4943
4943
|
"line": "שורה {line}",
|
|
4944
4944
|
"reviewing": {
|
|
4945
|
-
"
|
|
4946
|
-
"title": "
|
|
4947
|
-
"hint": "
|
|
4945
|
+
"planning": {
|
|
4946
|
+
"title": "בודק את בקשת המשיכה…",
|
|
4947
|
+
"hint": "הבודק עובר על ההבדלים. ההתקדמות לפי מקטע תופיע כאן לאחר שהשינויים יקובצו למקטעים."
|
|
4948
4948
|
},
|
|
4949
4949
|
"reviewingChunks": {
|
|
4950
4950
|
"title": "בודק את המקטעים…",
|
|
@@ -4961,7 +4961,7 @@
|
|
|
4961
4961
|
}
|
|
4962
4962
|
},
|
|
4963
4963
|
"phase": {
|
|
4964
|
-
"
|
|
4964
|
+
"planning": "בודק…",
|
|
4965
4965
|
"reviewing": "בודק {completed}/{total} מקטעים",
|
|
4966
4966
|
"awaiting": "הממצאים מוכנים",
|
|
4967
4967
|
"challenging": "חוקר…",
|
package/i18n/locales/it.json
CHANGED
|
@@ -4943,9 +4943,9 @@
|
|
|
4943
4943
|
"suggestedFix": "Correzione suggerita:",
|
|
4944
4944
|
"line": "riga {line}",
|
|
4945
4945
|
"reviewing": {
|
|
4946
|
-
"
|
|
4947
|
-
"title": "
|
|
4948
|
-
"hint": "
|
|
4946
|
+
"planning": {
|
|
4947
|
+
"title": "Revisione della pull request…",
|
|
4948
|
+
"hint": "Il revisore sta esaminando il diff. L'avanzamento per sezione comparirà qui una volta raggruppate le modifiche in sezioni."
|
|
4949
4949
|
},
|
|
4950
4950
|
"reviewingChunks": {
|
|
4951
4951
|
"title": "Revisione dei blocchi…",
|
|
@@ -4962,7 +4962,7 @@
|
|
|
4962
4962
|
}
|
|
4963
4963
|
},
|
|
4964
4964
|
"phase": {
|
|
4965
|
-
"
|
|
4965
|
+
"planning": "Revisione…",
|
|
4966
4966
|
"reviewing": "Revisione {completed}/{total} sezioni",
|
|
4967
4967
|
"awaiting": "Risultati pronti",
|
|
4968
4968
|
"challenging": "Analisi in corso…",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -4943,9 +4943,9 @@
|
|
|
4943
4943
|
"suggestedFix": "修正案:",
|
|
4944
4944
|
"line": "{line}行目",
|
|
4945
4945
|
"reviewing": {
|
|
4946
|
-
"
|
|
4947
|
-
"title": "
|
|
4948
|
-
"hint": "
|
|
4946
|
+
"planning": {
|
|
4947
|
+
"title": "プルリクエストをレビュー中…",
|
|
4948
|
+
"hint": "レビュー担当者が差分を確認しています。変更が区分にグループ化されると、区分ごとの進捗がここに表示されます。"
|
|
4949
4949
|
},
|
|
4950
4950
|
"reviewingChunks": {
|
|
4951
4951
|
"title": "チャンクをレビュー中…",
|
|
@@ -4962,7 +4962,7 @@
|
|
|
4962
4962
|
}
|
|
4963
4963
|
},
|
|
4964
4964
|
"phase": {
|
|
4965
|
-
"
|
|
4965
|
+
"planning": "レビュー中…",
|
|
4966
4966
|
"reviewing": "レビュー中 {completed}/{total} 区分",
|
|
4967
4967
|
"awaiting": "指摘の準備完了",
|
|
4968
4968
|
"challenging": "調査中…",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -4931,9 +4931,9 @@
|
|
|
4931
4931
|
"suggestedFix": "Sugerowana poprawka:",
|
|
4932
4932
|
"line": "wiersz {line}",
|
|
4933
4933
|
"reviewing": {
|
|
4934
|
-
"
|
|
4935
|
-
"title": "
|
|
4936
|
-
"hint": "
|
|
4934
|
+
"planning": {
|
|
4935
|
+
"title": "Przeglądanie pull requesta…",
|
|
4936
|
+
"hint": "Recenzent analizuje zmiany. Postęp poszczególnych fragmentów pojawi się tutaj po pogrupowaniu zmian we fragmenty."
|
|
4937
4937
|
},
|
|
4938
4938
|
"reviewingChunks": {
|
|
4939
4939
|
"title": "Przeglądanie fragmentów…",
|
|
@@ -4950,7 +4950,7 @@
|
|
|
4950
4950
|
}
|
|
4951
4951
|
},
|
|
4952
4952
|
"phase": {
|
|
4953
|
-
"
|
|
4953
|
+
"planning": "Przegląd…",
|
|
4954
4954
|
"reviewing": "Przegląd {completed}/{total} fragmentów",
|
|
4955
4955
|
"awaiting": "Wyniki gotowe",
|
|
4956
4956
|
"challenging": "Analizowanie…",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -4943,9 +4943,9 @@
|
|
|
4943
4943
|
"suggestedFix": "Önerilen düzeltme:",
|
|
4944
4944
|
"line": "satır {line}",
|
|
4945
4945
|
"reviewing": {
|
|
4946
|
-
"
|
|
4947
|
-
"title": "
|
|
4948
|
-
"hint": "
|
|
4946
|
+
"planning": {
|
|
4947
|
+
"title": "Pull request inceleniyor…",
|
|
4948
|
+
"hint": "İnceleyici farkı gözden geçiriyor. Değişiklikler bölümlere gruplandığında bölüm bazlı ilerleme burada görünecek."
|
|
4949
4949
|
},
|
|
4950
4950
|
"reviewingChunks": {
|
|
4951
4951
|
"title": "Parçalar inceleniyor…",
|
|
@@ -4962,7 +4962,7 @@
|
|
|
4962
4962
|
}
|
|
4963
4963
|
},
|
|
4964
4964
|
"phase": {
|
|
4965
|
-
"
|
|
4965
|
+
"planning": "İnceleniyor…",
|
|
4966
4966
|
"reviewing": "İnceleniyor {completed}/{total} bölüm",
|
|
4967
4967
|
"awaiting": "Bulgular hazır",
|
|
4968
4968
|
"challenging": "Araştırılıyor…",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -4931,9 +4931,9 @@
|
|
|
4931
4931
|
"suggestedFix": "Пропоноване виправлення:",
|
|
4932
4932
|
"line": "рядок {line}",
|
|
4933
4933
|
"reviewing": {
|
|
4934
|
-
"
|
|
4935
|
-
"title": "
|
|
4936
|
-
"hint": "
|
|
4934
|
+
"planning": {
|
|
4935
|
+
"title": "Перевірка pull request…",
|
|
4936
|
+
"hint": "Рецензент переглядає зміни. Прогрес за фрагментами з'явиться тут після групування змін у фрагменти."
|
|
4937
4937
|
},
|
|
4938
4938
|
"reviewingChunks": {
|
|
4939
4939
|
"title": "Огляд фрагментів…",
|
|
@@ -4950,7 +4950,7 @@
|
|
|
4950
4950
|
}
|
|
4951
4951
|
},
|
|
4952
4952
|
"phase": {
|
|
4953
|
-
"
|
|
4953
|
+
"planning": "Перевірка…",
|
|
4954
4954
|
"reviewing": "Перевірка {completed}/{total} фрагментів",
|
|
4955
4955
|
"awaiting": "Результати готові",
|
|
4956
4956
|
"challenging": "Дослідження…",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.147.
|
|
3
|
+
"version": "0.147.3",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|