@cat-factory/app 0.298.0 → 0.300.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/app/components/assistant/AssistantModal.logic.spec.ts +80 -0
- package/app/components/assistant/AssistantModal.logic.ts +45 -0
- package/app/components/assistant/AssistantModal.vue +260 -0
- package/app/composables/api/assistant.ts +29 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +22 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/assistant.ts +69 -0
- package/app/stores/brainstorm.ts +9 -2
- package/app/stores/clarity.ts +9 -2
- package/app/stores/consensus.ts +7 -2
- package/app/stores/docInterview.ts +3 -1
- package/app/stores/initiative.ts +4 -1
- package/app/stores/notifications.ts +2 -13
- package/app/stores/perKeyWrites.spec.ts +168 -0
- package/app/stores/requirements/recommendations.ts +25 -0
- package/app/stores/requirements.spec.ts +89 -1
- package/app/stores/requirements.ts +24 -19
- package/app/stores/ui/modals.ts +15 -0
- package/app/types/assistant.ts +24 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +49 -1
- package/i18n/locales/en.json +49 -1
- package/i18n/locales/es.json +49 -1
- package/i18n/locales/fr.json +49 -1
- package/i18n/locales/he.json +49 -1
- package/i18n/locales/it.json +49 -1
- package/i18n/locales/ja.json +49 -1
- package/i18n/locales/pl.json +49 -1
- package/i18n/locales/tr.json +49 -1
- package/i18n/locales/uk.json +49 -1
- package/package.json +3 -3
package/app/stores/consensus.ts
CHANGED
|
@@ -30,8 +30,13 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
30
30
|
return loading.value.has(blockId)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Write one block's session into the cache, IN PLACE: per-key, never a whole-record clone.
|
|
35
|
+
* `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
|
|
36
|
+
* an UNCHANGED block; assigning the key retriggers only the block that actually changed.
|
|
37
|
+
*/
|
|
33
38
|
function store(session: ConsensusSession) {
|
|
34
|
-
sessions.value
|
|
39
|
+
sessions.value[session.blockId] = session
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** Patch the cache from a live `consensus` stream event (newest wins per block). */
|
|
@@ -62,7 +67,7 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
62
67
|
if (session) {
|
|
63
68
|
if (!existing || session.updatedAt >= existing.updatedAt) store(session)
|
|
64
69
|
} else if (existing === undefined) {
|
|
65
|
-
sessions.value
|
|
70
|
+
sessions.value[blockId] = null
|
|
66
71
|
}
|
|
67
72
|
} catch {
|
|
68
73
|
// Consensus off / no session — leave the cache as-is; the window shows its empty state.
|
|
@@ -30,7 +30,9 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
|
|
|
30
30
|
function upsert(session: DocInterviewSession) {
|
|
31
31
|
const existing = byBlock.value[session.blockId]
|
|
32
32
|
if (existing && existing.updatedAt > session.updatedAt) return
|
|
33
|
-
byBlock
|
|
33
|
+
// Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
|
|
34
|
+
// record retriggered every consumer keyed on an UNCHANGED block.
|
|
35
|
+
byBlock.value[session.blockId] = session
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** Re-fetch one block's session (the interview window's load path). */
|
package/app/stores/initiative.ts
CHANGED
|
@@ -96,7 +96,10 @@ export const useInitiativesStore = defineStore('initiatives', () => {
|
|
|
96
96
|
function upsert(initiative: Initiative) {
|
|
97
97
|
const existing = byBlock.value[initiative.blockId]
|
|
98
98
|
if (existing && existing.rev > initiative.rev) return
|
|
99
|
-
byBlock
|
|
99
|
+
// Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
|
|
100
|
+
// record retriggered every consumer keyed on an UNCHANGED block. {@link hydrate} still
|
|
101
|
+
// replaces it wholesale, because a snapshot is authoritative for EXISTENCE.
|
|
102
|
+
byBlock.value[initiative.blockId] = initiative
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -16,8 +16,8 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
16
16
|
* Open, human-actionable notifications surfaced on the board (a PR awaiting a
|
|
17
17
|
* merge decision, a completed pipeline awaiting confirmation, CI that gave up).
|
|
18
18
|
* Hydrated from the workspace snapshot and patched live by the `notification`
|
|
19
|
-
* WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox
|
|
20
|
-
* per-block
|
|
19
|
+
* WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox from `open`, and the
|
|
20
|
+
* per-block review-wait stamps the swimlanes need from {@link reviewDebtByBlock}.
|
|
21
21
|
*/
|
|
22
22
|
export const useNotificationsStore = defineStore('notifications', () => {
|
|
23
23
|
const api = useApi()
|
|
@@ -110,16 +110,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
110
110
|
upsertOpen(notification)
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** Open notifications for a given block (for the board card badge). */
|
|
114
|
-
const byBlock = computed<Record<string, Notification[]>>(() => {
|
|
115
|
-
const map: Record<string, Notification[]> = {}
|
|
116
|
-
for (const n of open.value) {
|
|
117
|
-
if (!n.blockId) continue
|
|
118
|
-
;(map[n.blockId] ??= []).push(n)
|
|
119
|
-
}
|
|
120
|
-
return map
|
|
121
|
-
})
|
|
122
|
-
|
|
123
113
|
/**
|
|
124
114
|
* Per-block "waiting since", derived from the open review-wait cards by the same
|
|
125
115
|
* `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
|
|
@@ -213,7 +203,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
213
203
|
hydrate,
|
|
214
204
|
hydrateBaseline,
|
|
215
205
|
upsert,
|
|
216
|
-
byBlock,
|
|
217
206
|
reviewDebtByBlock,
|
|
218
207
|
count,
|
|
219
208
|
act,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { BrainstormSession } from '~/types/brainstorm'
|
|
4
|
+
import type { ClarityReview } from '~/types/clarity'
|
|
5
|
+
import type { ConsensusSession } from '~/types/consensus'
|
|
6
|
+
import type { DocInterviewSession, Initiative } from '~/types/domain'
|
|
7
|
+
import type { RequirementReview } from '~/types/requirements'
|
|
8
|
+
import { useBrainstormStore } from '~/stores/brainstorm'
|
|
9
|
+
import { useClarityStore } from '~/stores/clarity'
|
|
10
|
+
import { useConsensusStore } from '~/stores/consensus'
|
|
11
|
+
import { useDocInterviewStore } from '~/stores/docInterview'
|
|
12
|
+
import { useInitiativesStore } from '~/stores/initiative'
|
|
13
|
+
import { useRequirementsStore } from '~/stores/requirements'
|
|
14
|
+
|
|
15
|
+
// The review-family stores all hold a `Record<blockId, T>` in a DEEP reactive ref and all patch it
|
|
16
|
+
// from a live stream event. `x.value = { ...x.value, [id]: v }` is a write to the REF, a dependency
|
|
17
|
+
// every reader shares whatever key it reads, so one event woke every card on the board;
|
|
18
|
+
// `x.value[id] = v` keeps the invalidation on the key that changed. The rule is stated once in
|
|
19
|
+
// `frontend/app/README.md` ("A record keyed by block id is written PER KEY, never replaced").
|
|
20
|
+
//
|
|
21
|
+
// One table rather than six near-identical specs, because the property is one property and a store
|
|
22
|
+
// that JOINS this family should have exactly one obvious place to be added. Each row asserts the
|
|
23
|
+
// two halves that can regress independently: an event for another block does not invalidate this
|
|
24
|
+
// block's reader, and a FIRST write still reaches a reader that read the key while it was absent
|
|
25
|
+
// (Vue tracks a missing-key read, which is what makes the in-place write safe at all).
|
|
26
|
+
|
|
27
|
+
interface StoreCase {
|
|
28
|
+
name: string
|
|
29
|
+
/** Patch the store from a live event for `blockId`, tagged with `mark` so the read can see it. */
|
|
30
|
+
write: (blockId: string, mark: string) => void
|
|
31
|
+
/** What a card renders off that block: the tag, or null when the store holds nothing. */
|
|
32
|
+
read: (blockId: string) => string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cases: StoreCase[] = [
|
|
36
|
+
{
|
|
37
|
+
name: 'requirements',
|
|
38
|
+
write: (blockId, mark) => {
|
|
39
|
+
useRequirementsStore().upsert({
|
|
40
|
+
id: mark,
|
|
41
|
+
blockId,
|
|
42
|
+
status: 'ready',
|
|
43
|
+
iteration: 1,
|
|
44
|
+
maxIterations: 3,
|
|
45
|
+
items: [],
|
|
46
|
+
updatedAt: 1,
|
|
47
|
+
} as unknown as RequirementReview)
|
|
48
|
+
},
|
|
49
|
+
read: (blockId) => useRequirementsStore().reviewFor(blockId)?.id ?? null,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'clarity',
|
|
53
|
+
write: (blockId, mark) => {
|
|
54
|
+
useClarityStore().upsert({
|
|
55
|
+
id: mark,
|
|
56
|
+
blockId,
|
|
57
|
+
status: 'ready',
|
|
58
|
+
items: [],
|
|
59
|
+
updatedAt: 1,
|
|
60
|
+
} as unknown as ClarityReview)
|
|
61
|
+
},
|
|
62
|
+
read: (blockId) => useClarityStore().reviewFor(blockId)?.id ?? null,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'brainstorm',
|
|
66
|
+
// Keyed by block+STAGE, so a block legitimately holds one live session per stage; the
|
|
67
|
+
// invalidation still has to land on the one composite key that changed.
|
|
68
|
+
write: (blockId, mark) => {
|
|
69
|
+
useBrainstormStore().upsert({
|
|
70
|
+
id: mark,
|
|
71
|
+
blockId,
|
|
72
|
+
stage: 'requirements',
|
|
73
|
+
status: 'ready',
|
|
74
|
+
options: [],
|
|
75
|
+
updatedAt: 1,
|
|
76
|
+
} as unknown as BrainstormSession)
|
|
77
|
+
},
|
|
78
|
+
read: (blockId) => useBrainstormStore().sessionFor(blockId, 'requirements')?.id ?? null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'consensus',
|
|
82
|
+
write: (blockId, mark) => {
|
|
83
|
+
useConsensusStore().upsert({
|
|
84
|
+
id: mark,
|
|
85
|
+
blockId,
|
|
86
|
+
status: 'complete',
|
|
87
|
+
participants: [],
|
|
88
|
+
rounds: [],
|
|
89
|
+
synthesis: null,
|
|
90
|
+
createdAt: 1,
|
|
91
|
+
updatedAt: 1,
|
|
92
|
+
} as unknown as ConsensusSession)
|
|
93
|
+
},
|
|
94
|
+
read: (blockId) => useConsensusStore().sessionFor(blockId)?.id ?? null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'docInterview',
|
|
98
|
+
write: (blockId, mark) => {
|
|
99
|
+
useDocInterviewStore().upsert({
|
|
100
|
+
id: mark,
|
|
101
|
+
blockId,
|
|
102
|
+
status: 'awaiting_answers',
|
|
103
|
+
round: 1,
|
|
104
|
+
maxRounds: 3,
|
|
105
|
+
qa: [],
|
|
106
|
+
createdAt: 1,
|
|
107
|
+
updatedAt: 1,
|
|
108
|
+
} as unknown as DocInterviewSession)
|
|
109
|
+
},
|
|
110
|
+
read: (blockId) => useDocInterviewStore().forBlock(blockId)?.id ?? null,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'initiative',
|
|
114
|
+
write: (blockId, mark) => {
|
|
115
|
+
useInitiativesStore().upsert({
|
|
116
|
+
id: mark,
|
|
117
|
+
blockId,
|
|
118
|
+
slug: 'i',
|
|
119
|
+
title: 'I',
|
|
120
|
+
rev: 1,
|
|
121
|
+
} as unknown as Initiative)
|
|
122
|
+
},
|
|
123
|
+
read: (blockId) => useInitiativesStore().forBlock(blockId)?.id ?? null,
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
describe.each(cases)('$name store per-key writes', ({ write, read }) => {
|
|
128
|
+
it('an event for one block does not invalidate a consumer reading another', () => {
|
|
129
|
+
write('blk-a', 'a1')
|
|
130
|
+
|
|
131
|
+
let evaluations = 0
|
|
132
|
+
const forA = computed(() => {
|
|
133
|
+
evaluations++
|
|
134
|
+
return read('blk-a')
|
|
135
|
+
})
|
|
136
|
+
expect(forA.value).toBe('a1')
|
|
137
|
+
expect(evaluations).toBe(1)
|
|
138
|
+
|
|
139
|
+
// A brand-new key, then a rewrite of that existing one: neither is about `blk-a`.
|
|
140
|
+
write('blk-b', 'b1')
|
|
141
|
+
expect(forA.value).toBe('a1')
|
|
142
|
+
write('blk-b', 'b2')
|
|
143
|
+
expect(forA.value).toBe('a1')
|
|
144
|
+
expect(evaluations).toBe(1)
|
|
145
|
+
|
|
146
|
+
// The block's OWN event still reaches it.
|
|
147
|
+
write('blk-a', 'a2')
|
|
148
|
+
expect(forA.value).toBe('a2')
|
|
149
|
+
expect(evaluations).toBe(2)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('a FIRST write reaches a reader that read the key while it was absent', () => {
|
|
153
|
+
// The half an in-place write could plausibly lose: the reader tracked a key that did not
|
|
154
|
+
// exist. Vue tracks a missing-key read, so ADDING the key notifies it. Without this the
|
|
155
|
+
// window a card opens before its first event would render its empty state forever.
|
|
156
|
+
let evaluations = 0
|
|
157
|
+
const forC = computed(() => {
|
|
158
|
+
evaluations++
|
|
159
|
+
return read('blk-c')
|
|
160
|
+
})
|
|
161
|
+
expect(forC.value).toBeNull()
|
|
162
|
+
expect(evaluations).toBe(1)
|
|
163
|
+
|
|
164
|
+
write('blk-c', 'c1')
|
|
165
|
+
expect(forC.value).toBe('c1')
|
|
166
|
+
expect(evaluations).toBe(2)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -20,6 +20,31 @@ export interface RecommendationCommandContext {
|
|
|
20
20
|
hasPendingRecommendations: (blockId: string) => boolean
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Requirement Writer is still producing recommendations for THIS review: a `pending`
|
|
25
|
+
* placeholder exists. Server-derived, so the "Recommending…" state survives the window closing
|
|
26
|
+
* and a page reload; the client-local `recommending` set only covers the request round-trip.
|
|
27
|
+
*
|
|
28
|
+
* Memoised on the review OBJECT, like the settlement tallies next door, and for the same reason:
|
|
29
|
+
* the store REPLACES the review on every write, so identity self-invalidates and a superseded
|
|
30
|
+
* review is collected along with its answer.
|
|
31
|
+
*
|
|
32
|
+
* Per REVIEW rather than per record, which is the whole point. `backgroundStage` asks this on the
|
|
33
|
+
* per-CARD path, and a `computed` over the `reviews` record tracks the ref plus every key in it,
|
|
34
|
+
* so one review event would re-evaluate every card's stage, the fan-out the per-key write exists
|
|
35
|
+
* to remove. Reading one key depends on one key.
|
|
36
|
+
*/
|
|
37
|
+
const pendingByReview = new WeakMap<RequirementReview, boolean>()
|
|
38
|
+
|
|
39
|
+
export function awaitsRecommendations(review: RequirementReview): boolean {
|
|
40
|
+
let pending = pendingByReview.get(review)
|
|
41
|
+
if (pending === undefined) {
|
|
42
|
+
pending = (review.recommendations ?? []).some((r) => r.status === 'pending')
|
|
43
|
+
pendingByReview.set(review, pending)
|
|
44
|
+
}
|
|
45
|
+
return pending
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
|
|
24
49
|
export function createRecommendationCommands(ctx: RecommendationCommandContext) {
|
|
25
50
|
const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
-
import
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { RequirementRecommendation, RequirementReview } from '~/types/requirements'
|
|
3
4
|
import { useRequirementsStore } from '~/stores/requirements'
|
|
4
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
6
|
|
|
@@ -18,6 +19,20 @@ function review(over: Partial<RequirementReview> = {}): RequirementReview {
|
|
|
18
19
|
} as RequirementReview
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** A `pending` Writer placeholder: the state `backgroundStage` reads as "recommending". */
|
|
23
|
+
function pendingRecommendation(id: string): RequirementRecommendation {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
sourceFinding: { title: 'f', detail: 'd', itemId: 'i1' },
|
|
27
|
+
recommendedText: '',
|
|
28
|
+
status: 'pending',
|
|
29
|
+
note: null,
|
|
30
|
+
groundedInFragment: null,
|
|
31
|
+
createdAt: 1,
|
|
32
|
+
updatedAt: 1,
|
|
33
|
+
} as RequirementRecommendation
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
describe('requirements store load() loading flag', () => {
|
|
22
37
|
beforeEach(() => {
|
|
23
38
|
// The store resolves its workspace id from the workspace store at call time.
|
|
@@ -113,3 +128,76 @@ describe('requirements store live-event upsert guard', () => {
|
|
|
113
128
|
expect(store.reviewFor('b1')?.id).toBe('rr2')
|
|
114
129
|
})
|
|
115
130
|
})
|
|
131
|
+
|
|
132
|
+
describe('requirements store per-key writes', () => {
|
|
133
|
+
it('an event for one block does not invalidate a consumer reading another', () => {
|
|
134
|
+
// Every card on the board reads its OWN block's review (the "Recommending…"/gate badge), so
|
|
135
|
+
// one review event used to wake every card: the store replaced the whole record, which is a
|
|
136
|
+
// write to the ref itself and therefore a dependency every reader shares. Writing the key
|
|
137
|
+
// keeps the invalidation on the block that changed.
|
|
138
|
+
const store = useRequirementsStore()
|
|
139
|
+
store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
|
|
140
|
+
|
|
141
|
+
let evaluations = 0
|
|
142
|
+
const forA = computed(() => {
|
|
143
|
+
evaluations++
|
|
144
|
+
return store.reviewFor('blk-a')?.id ?? null
|
|
145
|
+
})
|
|
146
|
+
expect(forA.value).toBe('rr-a')
|
|
147
|
+
expect(evaluations).toBe(1)
|
|
148
|
+
|
|
149
|
+
// A brand-new key, then a rewrite of an existing one: neither is about `blk-a`.
|
|
150
|
+
store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 1 }))
|
|
151
|
+
expect(forA.value).toBe('rr-a')
|
|
152
|
+
store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 2 }))
|
|
153
|
+
expect(forA.value).toBe('rr-a')
|
|
154
|
+
expect(evaluations).toBe(1)
|
|
155
|
+
|
|
156
|
+
// The block's OWN event still reaches it.
|
|
157
|
+
store.upsert(review({ id: 'rr-a2', blockId: 'blk-a', updatedAt: 2 }))
|
|
158
|
+
expect(forA.value).toBe('rr-a2')
|
|
159
|
+
expect(evaluations).toBe(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('the per-card STAGE read depends on one block too, pending recommendations included', () => {
|
|
163
|
+
// `backgroundStage` is the read every card actually makes (TaskCard/BlockNode via
|
|
164
|
+
// `useReviewStage`), and it is the one the per-key write alone does not fix: while the pending
|
|
165
|
+
// -recommendation answer came from a `computed` over the whole `reviews` record, that computed
|
|
166
|
+
// tracked every key, so one event still re-evaluated the stage of every card on the board.
|
|
167
|
+
// Answering off the block's own review object is what closes it.
|
|
168
|
+
const store = useRequirementsStore()
|
|
169
|
+
store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
|
|
170
|
+
|
|
171
|
+
let evaluations = 0
|
|
172
|
+
const stageForA = computed(() => {
|
|
173
|
+
evaluations++
|
|
174
|
+
return store.backgroundStage('blk-a')
|
|
175
|
+
})
|
|
176
|
+
expect(stageForA.value).toBeNull()
|
|
177
|
+
expect(evaluations).toBe(1)
|
|
178
|
+
|
|
179
|
+
// Another block starts recommending: not this card's business.
|
|
180
|
+
store.upsert(
|
|
181
|
+
review({
|
|
182
|
+
id: 'rr-b',
|
|
183
|
+
blockId: 'blk-b',
|
|
184
|
+
updatedAt: 1,
|
|
185
|
+
recommendations: [pendingRecommendation('rec-1')],
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
expect(stageForA.value).toBeNull()
|
|
189
|
+
expect(evaluations).toBe(1)
|
|
190
|
+
|
|
191
|
+
// This block's own placeholder still surfaces the working state.
|
|
192
|
+
store.upsert(
|
|
193
|
+
review({
|
|
194
|
+
id: 'rr-a2',
|
|
195
|
+
blockId: 'blk-a',
|
|
196
|
+
updatedAt: 2,
|
|
197
|
+
recommendations: [pendingRecommendation('rec-2')],
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
expect(stageForA.value).toBe('recommending')
|
|
201
|
+
expect(evaluations).toBe(2)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import {
|
|
2
|
+
import { ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
4
|
RequirementReview,
|
|
5
5
|
ResolveRequirementsExceededChoice,
|
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
canProceed,
|
|
17
17
|
openCount,
|
|
18
18
|
} from '~/stores/requirements/settlement'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
awaitsRecommendations,
|
|
21
|
+
createRecommendationCommands,
|
|
22
|
+
} from '~/stores/requirements/recommendations'
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Requirements-review state. On the pipeline path the reviewer runs as the first gate
|
|
@@ -55,24 +58,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
55
58
|
function reviewFor(blockId: string): RequirementReview | null {
|
|
56
59
|
return reviews.value[blockId] ?? null
|
|
57
60
|
}
|
|
58
|
-
/** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
|
|
59
|
-
* placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
|
|
60
|
-
* and a page reload — the client-local `recommending` set only covers the request round-trip. */
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Whether the Requirement Writer is still producing recommendations for a block. Reads ONE key
|
|
63
|
+
* and answers off the review object itself ({@link awaitsRecommendations} memoises the scan on
|
|
64
|
+
* that object), so a card asking about its own block depends only on its own block. A `computed`
|
|
65
|
+
* over the whole record would be the fan-out again: it tracks every key, so one review event
|
|
66
|
+
* would re-evaluate the stage of every card on the board.
|
|
66
67
|
*/
|
|
67
|
-
const blocksAwaitingRecommendations = computed(() => {
|
|
68
|
-
const blocks = new Set<string>()
|
|
69
|
-
for (const [blockId, review] of Object.entries(reviews.value)) {
|
|
70
|
-
if ((review?.recommendations ?? []).some((r) => r.status === 'pending')) blocks.add(blockId)
|
|
71
|
-
}
|
|
72
|
-
return blocks
|
|
73
|
-
})
|
|
74
68
|
function hasPendingRecommendations(blockId: string): boolean {
|
|
75
|
-
|
|
69
|
+
const review = reviews.value[blockId]
|
|
70
|
+
return review ? awaitsRecommendations(review) : false
|
|
76
71
|
}
|
|
77
72
|
/**
|
|
78
73
|
* The async background stage a block's review is in, or null. While the driver folds the
|
|
@@ -96,8 +91,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
96
91
|
return incorporating.value.has(reviewId)
|
|
97
92
|
}
|
|
98
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Write one block's review into the cache, IN PLACE.
|
|
96
|
+
*
|
|
97
|
+
* Per-key, never a whole-record clone: `reviews` is a deep reactive ref, so a consumer reading
|
|
98
|
+
* `reviews[someBlockId]` depends on THAT key. Replacing the record retriggered every one of
|
|
99
|
+
* them (a card, a badge, an inspector panel for an untouched block) on every event; assigning
|
|
100
|
+
* the key retriggers only the consumers of the block that actually changed.
|
|
101
|
+
*/
|
|
99
102
|
function store(review: RequirementReview) {
|
|
100
|
-
reviews.value
|
|
103
|
+
reviews.value[review.blockId] = review
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
/** Patch the cache from a live `requirements` stream event (newest wins per block). */
|
|
@@ -139,7 +142,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
139
142
|
try {
|
|
140
143
|
const review = await api.getRequirementReview(workspace.requireId(), blockId)
|
|
141
144
|
available.value = true
|
|
142
|
-
|
|
145
|
+
// Written by key like `store()`, and directly because a load resolving to "none exists"
|
|
146
|
+
// caches a null the getter reads as "fetched, absent".
|
|
147
|
+
reviews.value[blockId] = review
|
|
143
148
|
} catch {
|
|
144
149
|
// 503 (feature off) or any error → hide the UI entry points.
|
|
145
150
|
available.value = false
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -216,6 +216,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
216
216
|
// the create-in target AND scopes the issue search to the frame's linked repo.
|
|
217
217
|
// Null → the unscoped "import an issue" surface (workspace-wide search).
|
|
218
218
|
const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
219
|
+
// In-app assistant: the prompt box that routes one sentence to one board action. It carries no
|
|
220
|
+
// subject (a turn resolves every name it needs from the board itself), so a plain flag says
|
|
221
|
+
// everything the host needs to know.
|
|
222
|
+
const assistantOpen = ref(false)
|
|
223
|
+
|
|
219
224
|
// Bug hunt: pick a tracker + one of its boards, rank its open unassigned bugs, adopt one.
|
|
220
225
|
// `containerId` (a service frame or module) preselects where an adopted bug lands; null →
|
|
221
226
|
// opened standalone, and the modal offers every container on the board.
|
|
@@ -293,6 +298,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
293
298
|
function closeTaskImport() {
|
|
294
299
|
taskImport.value = null
|
|
295
300
|
}
|
|
301
|
+
function openAssistant() {
|
|
302
|
+
resetHubReturn()
|
|
303
|
+
assistantOpen.value = true
|
|
304
|
+
}
|
|
305
|
+
function closeAssistant() {
|
|
306
|
+
assistantOpen.value = false
|
|
307
|
+
}
|
|
296
308
|
function openBugHunt(source: TaskSourceKind | null = null, containerId: string | null = null) {
|
|
297
309
|
resetHubReturn()
|
|
298
310
|
bugHunt.value = { source, containerId }
|
|
@@ -341,6 +353,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
341
353
|
spawnPreview,
|
|
342
354
|
taskConnect,
|
|
343
355
|
taskImport,
|
|
356
|
+
assistantOpen,
|
|
344
357
|
bugHunt,
|
|
345
358
|
startFromDesign,
|
|
346
359
|
addTaskContainerId,
|
|
@@ -360,6 +373,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
360
373
|
closeTaskConnect,
|
|
361
374
|
openTaskImport,
|
|
362
375
|
closeTaskImport,
|
|
376
|
+
openAssistant,
|
|
377
|
+
closeAssistant,
|
|
363
378
|
openBugHunt,
|
|
364
379
|
closeBugHunt,
|
|
365
380
|
openStartFromDesign,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The in-app assistant: one natural-language prompt, one action the platform performs on the
|
|
3
|
+
// person's behalf (declare a service dependency, add a service from a repository URL, file a task
|
|
4
|
+
// from a tracker issue).
|
|
5
|
+
//
|
|
6
|
+
// A turn answers with DATA, never with model prose: the outcome variant carries the ids and names
|
|
7
|
+
// of what was touched, or the machine-readable reason it could not act, and the SPA renders every
|
|
8
|
+
// sentence from those members through the i18n catalog. All wire shapes are sourced from
|
|
9
|
+
// @cat-factory/contracts (single source of truth).
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
AssistantActionId,
|
|
14
|
+
AssistantActionResult,
|
|
15
|
+
AssistantAnswer,
|
|
16
|
+
AssistantArguments,
|
|
17
|
+
AssistantCapability,
|
|
18
|
+
AssistantClarificationReason,
|
|
19
|
+
AssistantDeclineReason,
|
|
20
|
+
AssistantOutcome,
|
|
21
|
+
AssistantServiceRef,
|
|
22
|
+
AssistantTurn,
|
|
23
|
+
AssistantTurnInput,
|
|
24
|
+
} from '@cat-factory/contracts'
|
package/app/types/domain.ts
CHANGED
|
@@ -220,6 +220,7 @@ export type * from './skills'
|
|
|
220
220
|
export type * from './foundationalServices'
|
|
221
221
|
export type * from './documents'
|
|
222
222
|
export type * from './tasks'
|
|
223
|
+
export type * from './assistant'
|
|
223
224
|
export type * from './bugHunt'
|
|
224
225
|
export type * from './bootstrap'
|
|
225
226
|
export type * from './envConfigRepair'
|
package/i18n/locales/de.json
CHANGED
|
@@ -2712,6 +2712,7 @@
|
|
|
2712
2712
|
},
|
|
2713
2713
|
"cmd": {
|
|
2714
2714
|
"newPipeline": "Eine Pipeline bauen",
|
|
2715
|
+
"assistant": "Assistenten öffnen",
|
|
2715
2716
|
"addFromRepo": "Service aus vorhandenem Repo hinzufügen",
|
|
2716
2717
|
"bootstrapRepo": "Ein neues Repo bootstrappen",
|
|
2717
2718
|
"githubManage": "GitHub-Verbindung verwalten",
|
|
@@ -2739,6 +2740,7 @@
|
|
|
2739
2740
|
},
|
|
2740
2741
|
"keywords": {
|
|
2741
2742
|
"newPipeline": "pipeline agents chain",
|
|
2743
|
+
"assistant": "ki assistent fragen eingabe für mich erledigen",
|
|
2742
2744
|
"addFromRepo": "github import existing",
|
|
2743
2745
|
"bootstrapRepo": "scaffold create reference architecture",
|
|
2744
2746
|
"github": "git repos pull requests issues",
|
|
@@ -4514,6 +4516,49 @@
|
|
|
4514
4516
|
"missingBoard": "Wähle das Board aus, auf dem gesucht werden soll."
|
|
4515
4517
|
}
|
|
4516
4518
|
},
|
|
4519
|
+
"assistant": {
|
|
4520
|
+
"title": "Assistent",
|
|
4521
|
+
"intro": "Beschreiben Sie, was erledigt werden soll, und der Assistent führt es auf dem Board aus: eine Abhängigkeit zwischen zwei Services erklären, einen Service über eine Repository-URL hinzufügen oder eine Aufgabe aus einem Tracker-Ticket anlegen.",
|
|
4522
|
+
"unavailable": "Für diese Installation ist kein Modell konfiguriert, daher kann der Assistent keine Anfrage lesen. Richten Sie einen Modellanbieter ein, um ihn zu aktivieren.",
|
|
4523
|
+
"placeholder": "z. B. der Checkout-Service hängt vom Payments-Service ab",
|
|
4524
|
+
"submit": "Ausführen",
|
|
4525
|
+
"submitHint": "Strg+Enter, auf dem Mac Cmd+Enter",
|
|
4526
|
+
"examplesTitle": "Das können Sie fragen",
|
|
4527
|
+
"showOnBoard": "Auf dem Board zeigen",
|
|
4528
|
+
"declined": "Keine Aktion des Assistenten passt zu dieser Anfrage. Er kann eine Abhängigkeit zwischen zwei Services erklären, einen Service über eine Repository-URL hinzufügen oder eine Aufgabe aus einem Tracker-Ticket anlegen.",
|
|
4529
|
+
"actions": {
|
|
4530
|
+
"declare-service-dependency": {
|
|
4531
|
+
"label": "Service-Abhängigkeit erklären",
|
|
4532
|
+
"example": "der Checkout-Service hängt vom Payments-Service ab"
|
|
4533
|
+
},
|
|
4534
|
+
"add-service-from-repo": {
|
|
4535
|
+
"label": "Service aus einem Repository hinzufügen",
|
|
4536
|
+
"example": "füge https://github.com/acme/payments als Service hinzu"
|
|
4537
|
+
},
|
|
4538
|
+
"create-task-from-issue": {
|
|
4539
|
+
"label": "Aufgabe aus einem Ticket anlegen",
|
|
4540
|
+
"example": "erstelle eine Aufgabe aus https://github.com/acme/payments/issues/12"
|
|
4541
|
+
}
|
|
4542
|
+
},
|
|
4543
|
+
"done": {
|
|
4544
|
+
"dependencyDeclared": "{consumer} hängt jetzt von {provider} ab, beide werden also gestartet, wenn {consumer} getestet wird.",
|
|
4545
|
+
"dependencyAlready": "{consumer} hängt bereits von {provider} ab, es hat sich nichts geändert.",
|
|
4546
|
+
"serviceAdded": "{service} hinzugefügt, gestützt auf {repo}.",
|
|
4547
|
+
"serviceMounted": "{repo} liegt bereits einem Service dieser Organisation zugrunde, deshalb wurde {service} auf dieses Board gelegt statt neu angelegt.",
|
|
4548
|
+
"taskCreated": "{issue} als „{task}“ unter {service} angelegt."
|
|
4549
|
+
},
|
|
4550
|
+
"needsInput": {
|
|
4551
|
+
"missing_argument": "In dieser Anfrage fehlt etwas, das der Assistent braucht. Formulieren Sie sie vollständig und versuchen Sie es erneut.",
|
|
4552
|
+
"invalid_argument": "Einer der Werte in dieser Anfrage ist nicht verwendbar. Prüfen Sie ihn und versuchen Sie es erneut.",
|
|
4553
|
+
"unknown_service": "Kein Service auf diesem Board trägt diesen Namen.",
|
|
4554
|
+
"ambiguous_service": "Mehrere Services passen zu diesem Namen. Welchen meinen Sie?",
|
|
4555
|
+
"unknown_repository": "Dieser Workspace hat keine Verbindung zu diesem Repository. Verbinden Sie es zuerst und fragen Sie dann erneut.",
|
|
4556
|
+
"unreadable_repository_url": "Das ist keine Repository-URL, die der Assistent lesen kann. Fügen Sie die Webadresse des Repositorys ein.",
|
|
4557
|
+
"unknown_issue_source": "Kein verbundener Tracker erkennt diesen Ticket-Link. Verbinden Sie zuerst den zugehörigen Tracker.",
|
|
4558
|
+
"ambiguous_issue_source": "Zwei verbundene Tracker erkennen diese Angabe. Fügen Sie stattdessen die vollständige Webadresse des Tickets ein.",
|
|
4559
|
+
"unresolved_issue_service": "Der Assistent konnte nicht erkennen, unter welchem Service das angelegt werden soll. Nennen Sie den Service in Ihrer Anfrage."
|
|
4560
|
+
}
|
|
4561
|
+
},
|
|
4517
4562
|
"pipeline": {
|
|
4518
4563
|
"iterationCap": {
|
|
4519
4564
|
"extraRound": "Noch eine Runde",
|
|
@@ -6095,6 +6140,7 @@
|
|
|
6095
6140
|
"collapseSidebar": "Seitenleiste einklappen",
|
|
6096
6141
|
"commandBar": "Suchen oder einen Befehl ausführen…",
|
|
6097
6142
|
"create": "Erstellen",
|
|
6143
|
+
"assistant": "Assistent",
|
|
6098
6144
|
"buildPipeline": "Eine Pipeline erstellen",
|
|
6099
6145
|
"repositories": "Repositories",
|
|
6100
6146
|
"addFromRepo": "Aus vorhandenem Repo hinzufügen",
|
|
@@ -6144,7 +6190,9 @@
|
|
|
6144
6190
|
"service_catalog_unreachable": "Das Entwicklerportal dieses Boards hat nicht geantwortet oder eine Antwort geliefert, die diese Plattform nicht lesen kann. Hier ist nichts falsch konfiguriert und keine Änderung hilft: versuche es erneut, sobald das Portal erreichbar ist.",
|
|
6145
6191
|
"service_catalog_unauthorized": "Das Entwicklerportal hat die gespeicherte Zugangsdaten abgelehnt. Sie wurden wahrscheinlich rotiert oder widerrufen: öffne die Servicekatalog-Einstellungen und trage aktuelle ein.",
|
|
6146
6192
|
"service_catalog_filter_missing": "Der Entitätsfilter dieser Verbindung konnte nicht gelesen werden, es gibt also nichts zu importieren. Speichere die Servicekatalog-Verbindung erneut, um ihn wiederherzustellen.",
|
|
6147
|
-
"service_catalog_response_too_large": "Das Entwicklerportal hat mit mehr Daten geantwortet, als diese Plattform in einer Antwort aufnimmt. Öffne die Servicekatalog-Einstellungen und senke das Service-Limit oder deaktiviere den Import von Schnittstellendefinitionen, und importiere dann erneut."
|
|
6193
|
+
"service_catalog_response_too_large": "Das Entwicklerportal hat mit mehr Daten geantwortet, als diese Plattform in einer Antwort aufnimmt. Öffne die Servicekatalog-Einstellungen und senke das Service-Limit oder deaktiviere den Import von Schnittstellendefinitionen, und importiere dann erneut.",
|
|
6194
|
+
"assistant_generation_failed": "Der Modellanbieter des Assistenten hat nicht geantwortet. Er ist korrekt konfiguriert und hier muss nichts eingerichtet werden: Versuchen Sie die Anfrage gleich noch einmal.",
|
|
6195
|
+
"assistant_reply_unreadable": "Das Modell des Assistenten hat etwas geantwortet, das diese Plattform nicht als Entscheidung ausführen kann. Eine Umformulierung hilft selten: Wählen Sie in den Modelleinstellungen ein anderes Modell für den Assistenten."
|
|
6148
6196
|
}
|
|
6149
6197
|
},
|
|
6150
6198
|
"reason": {
|