@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.
@@ -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
- slicing: { icon: 'i-lucide-loader-circle', spin: true, class: 'text-indigo-300' },
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
- slicing: 'prReview.phase.slicing',
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, isSlicingChunks } from '~/utils/prReviewProgress'
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 two `reviewing`-phase sub-states from it lives in `~/utils/prReviewProgress`
59
- // (pure + unit-tested); see that module for the slicing-vs-reviewing signal.
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
- * Slicing sub-phase: the reviewer is still grouping the diff into chunks (no todo list yet).
64
- * Once the list exists, slicing is done and we render the per-chunk status list instead.
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 slicing = computed(() => status.value === 'reviewing' && isSlicingChunks(subtasks.value))
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. The phase is told apart precisely —
260
- SLICING (still grouping the diff into chunks, no plan yet) vs REVIEWING (slicing done,
261
- working through the chunks) so the copy never claims "reviewing" while it's slicing. -->
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
- <!-- SLICING: no todo list yet — the reviewer is still grouping the diff into chunks. -->
271
+ <!-- PLANNING: no per-slice plan yet — a neutral "reviewing…" state, not a "slicing" claim. -->
268
272
  <div
269
- v-if="slicing"
270
- data-testid="pr-review-slicing"
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.slicing.title') }}</p>
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.slicing.hint') }}
280
+ {{ t('prReview.reviewing.planning.hint') }}
277
281
  </p>
278
282
  </div>
279
283
 
280
- <!-- REVIEWING: slicing is done — show every chunk with its status + which are active now. -->
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 { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
3
+ import {
4
+ personalPasswordCacheKey,
5
+ usePersonalSubscriptionsStore,
6
+ } from '~/stores/personalSubscriptions'
4
7
 
5
- // The single localStorage key the store caches the personal password under (private to the
6
- // store; hard-coded here so the buffer/expiry semantics can be asserted directly).
7
- const CACHE_KEY = 'cf.personal-pw'
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(CACHE_KEY, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
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 under a
16
- // SINGLE key with a TTL: the backend gate applies one password to ALL of a run's
17
- // individual-usage vendors, so there's no point keying the cache per vendor. A task
18
- // start/retry (and each interaction with a live run) rides along with the cached password —
19
- // sent as the `X-Personal-Password` header — and the user is only re-prompted once it
20
- // expires (or is wrong).
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
- const CACHE_KEY = 'cf.personal-pw'
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 raw = localStorage.getItem(CACHE_KEY)
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(CACHE_KEY)
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
- CACHE_KEY,
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(CACHE_KEY)
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
- isSlicingChunks,
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('isSlicingChunks', () => {
18
- it('is slicing when there is no todo list yet (null/undefined or empty)', () => {
19
- // No plan committed the reviewer is still grouping the diff into chunks.
20
- expect(isSlicingChunks(null)).toBe(true)
21
- expect(isSlicingChunks(undefined)).toBe(true)
22
- expect(isSlicingChunks(subtasks({ total: 0 }))).toBe(true)
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('is not slicing once the todo list exists (slicing is done)', () => {
26
- expect(isSlicingChunks(subtasks({ total: 3, completed: 1 }))).toBe(false)
27
- // A single-chunk plan still counts as sliced — a plan of size 1 is a committed plan.
28
- expect(isSlicingChunks(subtasks({ total: 1 }))).toBe(false)
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 slicing while reviewing with no todo list yet (counts zeroed)', () => {
93
- // No plan committeddon't leak a misleading 0/0 slice count.
93
+ it('is the neutral `planning` kind while reviewing with no todo list yet (counts zeroed)', () => {
94
+ // No plan reportedsurface 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: 'slicing',
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: 'slicing',
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
- // The reviewer maintains a per-slice todo list (`step.subtasks`) once it has grouped the diff
7
- // into cohesive chunks. The PRESENCE of that list is the signal that slicing is done, so the two
8
- // `reviewing` sub-phases are told apart by it:
9
- // - no todo list yet (`total === 0`) still SLICING the diff into chunks (no plan committed).
10
- // - todo list present (`total > 0`) slicing DONE, working through the chunks.
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
- /** True while the reviewer is still grouping the diff into chunks (it has not committed a plan). */
13
- export function isSlicingChunks(subtasks: StepSubtasks | null | undefined): boolean {
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
- * slicing-vs-reviewing signal from the todo list) into a single kind the board surfaces label
33
- * without re-deriving. `completed`/`total` carry the slice counts for the `reviewing` kind.
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
- | 'slicing'
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 slicing). */
57
+ /** Slices whose review is finished (0 while planning). */
48
58
  completed: number
49
- /** Total slices the reviewer grouped the diff into (0 while slicing). */
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 todo list yet ⇒ still grouping the diff; otherwise working through the chunks.
64
- return isSlicingChunks(subtasks)
65
- ? { kind: 'slicing', completed: 0, total: 0 }
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 }
@@ -4943,9 +4943,9 @@
4943
4943
  "suggestedFix": "Lösungsvorschlag:",
4944
4944
  "line": "Zeile {line}",
4945
4945
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Diff wird in Abschnitte zerlegt…",
4948
- "hint": "Die geänderten Dateien werden in zusammenhängende Abschnitte gruppiert, damit jeder einzeln geprüft werden kann. Die Abschnittsliste erscheint hier, sobald die Zerlegung abgeschlossen ist."
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
- "slicing": "Wird aufgeteilt…",
4965
+ "planning": "Wird geprüft…",
4966
4966
  "reviewing": "Prüfe {completed}/{total} Abschnitte",
4967
4967
  "awaiting": "Ergebnisse bereit",
4968
4968
  "challenging": "Wird untersucht…",
@@ -5072,9 +5072,9 @@
5072
5072
  "suggestedFix": "Suggested fix:",
5073
5073
  "line": "line {line}",
5074
5074
  "reviewing": {
5075
- "slicing": {
5076
- "title": "Slicing the diff into chunks…",
5077
- "hint": "Grouping the changed files into cohesive chunks so each can be reviewed on its own. The chunk list appears here once slicing is done."
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
- "slicing": "Slicing…",
5094
+ "planning": "Reviewing…",
5095
5095
  "reviewing": "Reviewing {completed}/{total} slices",
5096
5096
  "awaiting": "Findings ready",
5097
5097
  "challenging": "Investigating…",
@@ -4931,9 +4931,9 @@
4931
4931
  "suggestedFix": "Corrección sugerida:",
4932
4932
  "line": "línea {line}",
4933
4933
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Dividiendo el diff en fragmentos…",
4936
- "hint": "Agrupando los archivos modificados en fragmentos coherentes para revisar cada uno por separado. La lista de fragmentos aparecerá aquí cuando termine la división."
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
- "slicing": "Dividiendo…",
4953
+ "planning": "Revisando…",
4954
4954
  "reviewing": "Revisando {completed}/{total} secciones",
4955
4955
  "awaiting": "Resultados listos",
4956
4956
  "challenging": "Investigando…",
@@ -4931,9 +4931,9 @@
4931
4931
  "suggestedFix": "Correction suggérée :",
4932
4932
  "line": "ligne {line}",
4933
4933
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Découpage du diff en sections…",
4936
- "hint": "Regroupement des fichiers modifiés en sections cohérentes afin de pouvoir examiner chacune séparément. La liste des sections apparaîtra ici une fois le découpage terminé."
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
- "slicing": "Découpage…",
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…",
@@ -4942,9 +4942,9 @@
4942
4942
  "suggestedFix": "תיקון מוצע:",
4943
4943
  "line": "שורה {line}",
4944
4944
  "reviewing": {
4945
- "slicing": {
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
- "slicing": "מחלק…",
4964
+ "planning": "בודק…",
4965
4965
  "reviewing": "בודק {completed}/{total} מקטעים",
4966
4966
  "awaiting": "הממצאים מוכנים",
4967
4967
  "challenging": "חוקר…",
@@ -4943,9 +4943,9 @@
4943
4943
  "suggestedFix": "Correzione suggerita:",
4944
4944
  "line": "riga {line}",
4945
4945
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Suddivisione del diff in blocchi…",
4948
- "hint": "Raggruppamento dei file modificati in blocchi coerenti per esaminare ciascuno separatamente. L'elenco dei blocchi comparirà qui al termine della suddivisione."
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
- "slicing": "Suddivisione…",
4965
+ "planning": "Revisione…",
4966
4966
  "reviewing": "Revisione {completed}/{total} sezioni",
4967
4967
  "awaiting": "Risultati pronti",
4968
4968
  "challenging": "Analisi in corso…",
@@ -4943,9 +4943,9 @@
4943
4943
  "suggestedFix": "修正案:",
4944
4944
  "line": "{line}行目",
4945
4945
  "reviewing": {
4946
- "slicing": {
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
- "slicing": "分割中…",
4965
+ "planning": "レビュー中…",
4966
4966
  "reviewing": "レビュー中 {completed}/{total} 区分",
4967
4967
  "awaiting": "指摘の準備完了",
4968
4968
  "challenging": "調査中…",
@@ -4931,9 +4931,9 @@
4931
4931
  "suggestedFix": "Sugerowana poprawka:",
4932
4932
  "line": "wiersz {line}",
4933
4933
  "reviewing": {
4934
- "slicing": {
4935
- "title": "Dzielenie zmian na fragmenty…",
4936
- "hint": "Grupowanie zmienionych plików w spójne fragmenty, aby każdy można było przejrzeć osobno. Lista fragmentów pojawi się tutaj po zakończeniu podziału."
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
- "slicing": "Dzielenie…",
4953
+ "planning": "Przegląd…",
4954
4954
  "reviewing": "Przegląd {completed}/{total} fragmentów",
4955
4955
  "awaiting": "Wyniki gotowe",
4956
4956
  "challenging": "Analizowanie…",
@@ -4943,9 +4943,9 @@
4943
4943
  "suggestedFix": "Önerilen düzeltme:",
4944
4944
  "line": "satır {line}",
4945
4945
  "reviewing": {
4946
- "slicing": {
4947
- "title": "Fark parçalara bölünüyor…",
4948
- "hint": "Değiştirilen dosyalar, her biri ayrı ayrı incelenebilsin diye tutarlı parçalara gruplanıyor. Bölme tamamlandığında parça listesi burada görünecek."
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
- "slicing": "Dilimleniyor…",
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…",
@@ -4931,9 +4931,9 @@
4931
4931
  "suggestedFix": "Пропоноване виправлення:",
4932
4932
  "line": "рядок {line}",
4933
4933
  "reviewing": {
4934
- "slicing": {
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
- "slicing": "Розбиття…",
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.2",
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",