@cat-factory/app 0.237.0 → 0.238.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.
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { useBoardStore } from '~/stores/board'
5
+ import { useContainerTargets } from '~/composables/useContainerTargets'
6
+
7
+ /**
8
+ * Where the frame-header authoring surfaces create. Two properties matter and only one of them is
9
+ * about the happy path: the frame a surface was opened from narrows the choice to that service
10
+ * WITHOUT removing its modules as targets, and the answer follows a live board, because the frame
11
+ * can be deleted (by another member, over the socket) while the surface sits open.
12
+ */
13
+ const block = (over: Partial<Block> & Pick<Block, 'id' | 'level' | 'title'>): Block =>
14
+ ({ parentId: null, ...over }) as Block
15
+
16
+ /** A board with two services, one of which has modules, plus a task (never a container). */
17
+ function seedBoard(): void {
18
+ useBoardStore().blocks = [
19
+ block({ id: 'f_auth', level: 'frame', title: 'Auth' }),
20
+ block({ id: 'm_login', level: 'module', title: 'Login', parentId: 'f_auth' }),
21
+ block({ id: 'm_tokens', level: 'module', title: 'Tokens', parentId: 'f_auth' }),
22
+ block({ id: 'f_billing', level: 'frame', title: 'Billing' }),
23
+ block({ id: 't_1', level: 'task', title: 'A task', parentId: 'f_billing' }),
24
+ ]
25
+ }
26
+
27
+ describe('useContainerTargets', () => {
28
+ it('scopes to the opening frame and its modules, keeping the module as a target', () => {
29
+ seedBoard()
30
+ const { items, containerId, stated, pinned, reset } = useContainerTargets(() => 'f_auth')
31
+ reset()
32
+ // The button that opened this names the service, so the modules need no parent prefix.
33
+ expect(items.value).toEqual([
34
+ { label: 'Auth', value: 'f_auth' },
35
+ { label: 'Login', value: 'm_login' },
36
+ { label: 'Tokens', value: 'm_tokens' },
37
+ ])
38
+ expect(pinned.value?.id).toBe('f_auth')
39
+ // A frame with modules did NOT answer frame-or-which-module, so the picker still asks,
40
+ // preselected to the frame itself.
41
+ expect(stated.value).toBe(false)
42
+ expect(containerId.value).toBe('f_auth')
43
+ })
44
+
45
+ it('states the target rather than asking when the opening frame has no modules', () => {
46
+ seedBoard()
47
+ const { items, containerId, stated, reset } = useContainerTargets(() => 'f_billing')
48
+ reset()
49
+ expect(items.value).toEqual([{ label: 'Billing', value: 'f_billing' }])
50
+ expect(stated.value).toBe(true)
51
+ expect(containerId.value).toBe('f_billing')
52
+ })
53
+
54
+ it('offers every container on the board, parent-labelled, when opened standalone', () => {
55
+ seedBoard()
56
+ const { items, stated, reset } = useContainerTargets(() => null)
57
+ reset()
58
+ expect(items.value).toEqual([
59
+ { label: 'Auth', value: 'f_auth' },
60
+ { label: 'Auth › Login', value: 'm_login' },
61
+ { label: 'Auth › Tokens', value: 'm_tokens' },
62
+ { label: 'Billing', value: 'f_billing' },
63
+ ])
64
+ expect(stated.value).toBe(false)
65
+ })
66
+
67
+ // The finding this composable exists for: an id is not evidence the block is still there, so the
68
+ // surface widens back to the whole board AND re-derives its selection. Leaving the selection
69
+ // behind is the silent half: the picker renders unselected while the search under it stays scoped
70
+ // to the deleted frame and the create lands on an id the board no longer has.
71
+ it('widens and re-selects when the opening frame is deleted underneath it', async () => {
72
+ seedBoard()
73
+ const openedFrom = ref<string | null>('f_auth')
74
+ const { items, containerId, stated, pinned } = useContainerTargets(() => openedFrom.value)
75
+ containerId.value = 'm_login'
76
+
77
+ const board = useBoardStore()
78
+ board.blocks = board.blocks.filter((b) => !['f_auth', 'm_login', 'm_tokens'].includes(b.id))
79
+ await nextTick()
80
+
81
+ expect(pinned.value).toBeUndefined()
82
+ expect(stated.value).toBe(false)
83
+ expect(items.value).toEqual([{ label: 'Billing', value: 'f_billing' }])
84
+ expect(containerId.value).toBe('f_billing')
85
+ })
86
+
87
+ it('keeps a still-legal selection when a sibling module is added', async () => {
88
+ seedBoard()
89
+ const { containerId } = useContainerTargets(() => 'f_auth')
90
+ containerId.value = 'm_login'
91
+
92
+ const board = useBoardStore()
93
+ board.blocks = [
94
+ ...board.blocks,
95
+ block({ id: 'm_sessions', level: 'module', title: 'Sessions', parentId: 'f_auth' }),
96
+ ]
97
+ await nextTick()
98
+
99
+ expect(containerId.value).toBe('m_login')
100
+ })
101
+
102
+ // A block id that resolves to something no task can live in is the same fact as an id that
103
+ // resolves to nothing: the surface has no frame behind it and must not pin to one.
104
+ it('ignores an opening id that is not a legal container', () => {
105
+ seedBoard()
106
+ const { pinned, items, reset, containerId } = useContainerTargets(() => 't_1')
107
+ reset()
108
+ expect(pinned.value).toBeUndefined()
109
+ expect(items.value).toHaveLength(4)
110
+ expect(containerId.value).toBe('f_auth')
111
+ })
112
+ })
@@ -0,0 +1,62 @@
1
+ import { computed, ref, watch, type Ref } from 'vue'
2
+ import type { Block } from '~/types/domain'
3
+ import { useBoardStore } from '~/stores/board'
4
+ import {
5
+ containerTargets,
6
+ isTaskContainer,
7
+ reconcileContainer,
8
+ type ContainerTarget,
9
+ } from '~/utils/containerTargets'
10
+
11
+ /**
12
+ * Where a board-authoring surface creates, tracking a live board.
13
+ *
14
+ * `openedFrom` is the frame id the surface was opened WITH, and is resolved through the board on
15
+ * every read rather than trusted: an id alone cannot say whether the block still exists or was ever
16
+ * a legal container. When it resolves and the frame holds no modules there is exactly one answer,
17
+ * so `stated` is true and the caller renders a line naming it instead of a picker asking a question
18
+ * the header button already answered. A frame WITH modules did not answer it, so the picker stays,
19
+ * scoped to that frame.
20
+ *
21
+ * Shared by `<TaskImportModal>` and `<BugHuntModal>`, which are opened from the same two frame
22
+ * header buttons with the same payload: as two copies they disagreed about whether the frame was
23
+ * the answer or the question.
24
+ */
25
+ export function useContainerTargets(openedFrom: () => string | null | undefined): {
26
+ /** The frame or module the surface was opened from, while the board still holds it. */
27
+ pinned: Ref<Block | undefined>
28
+ items: Ref<ContainerTarget[]>
29
+ containerId: Ref<string | undefined>
30
+ /** One legal target, so the surface states where the work lands rather than asking. */
31
+ stated: Ref<boolean>
32
+ /** Re-seed the selection; the caller invokes this when the surface opens. */
33
+ reset: () => void
34
+ } {
35
+ const board = useBoardStore()
36
+
37
+ const pinned = computed<Block | undefined>(() => {
38
+ const id = openedFrom()
39
+ const block = id ? board.getBlock(id) : undefined
40
+ return isTaskContainer(block) ? block : undefined
41
+ })
42
+
43
+ const items = computed(() => containerTargets(board.blocks, pinned.value))
44
+
45
+ const containerId = ref<string | undefined>(undefined)
46
+
47
+ function reset() {
48
+ containerId.value = reconcileContainer(items.value, pinned.value?.id)
49
+ }
50
+
51
+ // The board is live, so what is on offer moves under the open surface: a deleted frame, a new
52
+ // module. Watching the TARGETS rather than seeding once is what keeps the selection legal, and
53
+ // it is the reason the pinned frame is re-resolved on every read instead of captured on open.
54
+ watch(items, (next) => {
55
+ const resolved = reconcileContainer(next, containerId.value)
56
+ if (resolved !== containerId.value) containerId.value = resolved
57
+ })
58
+
59
+ const stated = computed(() => !!pinned.value && items.value.length === 1)
60
+
61
+ return { pinned, items, containerId, stated, reset }
62
+ }
@@ -3,10 +3,14 @@ import type { LodLevel } from '~/types/domain'
3
3
  import { zoomToLod } from '~/composables/useSemanticZoom'
4
4
 
5
5
  /**
6
- * The board-navigation slice of the UI store: selection / focus, canvas zoom + the derived
7
- * level-of-detail, and the (retained) expanded-frame set. Hot paths (zoom/pan/select) live here,
8
- * isolated from the modal + result-view state, per refactoring candidate #4. Composed into
9
- * {@link useUiStore}; the returned refs/actions keep their names, so consumers are unchanged.
6
+ * The board-navigation slice of the UI store: selection / focus, and canvas zoom plus the derived
7
+ * level-of-detail. Hot paths (zoom/pan/select) live here, isolated from the modal + result-view
8
+ * state, per refactoring candidate #4. Composed into {@link useUiStore}; the returned refs/actions
9
+ * keep their names, so consumers are unchanged.
10
+ *
11
+ * There is deliberately no per-frame expanded/collapsed state: a service is always expanded to its
12
+ * task canvas, at every zoom level, so the board layout is fixed. The set that used to hold it
13
+ * outlived the last branch that read it and went with them.
10
14
  */
11
15
  export function createUiNavigation() {
12
16
  const selectedBlockId = ref<string | null>(null)
@@ -17,29 +21,6 @@ export function createUiNavigation() {
17
21
 
18
22
  const lod = computed<LodLevel>(() => zoomToLod(zoom.value))
19
23
 
20
- /** Frames the user has manually expanded to reveal their tasks. */
21
- const expandedFrames = ref<Set<string>>(new Set())
22
-
23
- function toggleFrame(id: string) {
24
- const next = new Set(expandedFrames.value)
25
- if (next.has(id)) next.delete(id)
26
- else next.add(id)
27
- expandedFrames.value = next
28
- }
29
-
30
- function expandFrame(id: string) {
31
- if (expandedFrames.value.has(id)) return
32
- expandedFrames.value = new Set(expandedFrames.value).add(id)
33
- }
34
-
35
- /** Services are always expanded to their task canvas, at every zoom level, so the
36
- * board layout is fixed: panning never changes it and zooming has no expand/collapse
37
- * transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
38
- * no longer gate rendering.) */
39
- function isFrameExpanded(_id: string) {
40
- return true
41
- }
42
-
43
24
  function select(id: string | null) {
44
25
  selectedBlockId.value = id
45
26
  }
@@ -53,10 +34,6 @@ export function createUiNavigation() {
53
34
  focusBlockId,
54
35
  zoom,
55
36
  lod,
56
- expandedFrames,
57
- toggleFrame,
58
- expandFrame,
59
- isFrameExpanded,
60
37
  select,
61
38
  focus,
62
39
  }
@@ -6,6 +6,8 @@
6
6
  // member added on one side only renders as a blank chip rather than failing to compile.
7
7
 
8
8
  export type {
9
+ StepToolServers,
10
+ ToolServerUnavailableReason,
9
11
  ToolServerAllowedToolsCheck,
10
12
  ToolServerCredential,
11
13
  ToolServerNotProbeableReason,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a board-authoring surface creates: the containers a new task may land in, and how that
3
+ * answer follows a live board.
4
+ *
5
+ * Pure so the reconcile below can be pinned by a unit test. `<TaskImportModal>` and
6
+ * `<BugHuntModal>` ask the same two-part question (a source to read, a container to land in) and
7
+ * are opened from the same frame-header buttons, so both read this through
8
+ * {@link useContainerTargets} rather than each carrying its own copy.
9
+ */
10
+ import type { Block } from '~/types/domain'
11
+
12
+ /** A container offered as a select item. */
13
+ export interface ContainerTarget {
14
+ label: string
15
+ value: string
16
+ }
17
+
18
+ /** Only a service frame or one of its modules can hold a task. */
19
+ export function isTaskContainer(block: Block | undefined): block is Block {
20
+ return !!block && (block.level === 'frame' || block.level === 'module')
21
+ }
22
+
23
+ /**
24
+ * The containers on offer, given the frame the surface was opened from (if any).
25
+ *
26
+ * Opened from a service frame the answer is SCOPED to that frame: the frame itself plus its
27
+ * modules, each labelled by its own title because the frame is already named on the surface. The
28
+ * frame settles WHICH SERVICE the work belongs to, which is the part a header button can answer;
29
+ * it does not settle frame-or-which-module, so a frame that has modules still owes the user a
30
+ * choice. Opened standalone there is no frame behind it, so every container on the board is a
31
+ * candidate and a module carries its parent's title to keep the choice unambiguous.
32
+ *
33
+ * `pinned` is resolved through the board by the caller rather than trusted as an id, so a frame
34
+ * deleted while the surface sat open widens back to the whole board instead of scoping to
35
+ * something nothing can be created in.
36
+ */
37
+ export function containerTargets(
38
+ blocks: readonly Block[],
39
+ pinned: Block | undefined,
40
+ ): ContainerTarget[] {
41
+ if (pinned) {
42
+ // Modules cannot nest, so a pinned module is already the only answer.
43
+ if (pinned.level === 'module') return [{ label: pinned.title, value: pinned.id }]
44
+ const modules = blocks.filter((b) => b.level === 'module' && b.parentId === pinned.id)
45
+ return [pinned, ...modules].map((b) => ({ label: b.title, value: b.id }))
46
+ }
47
+ const byId = new Map(blocks.map((b) => [b.id, b]))
48
+ return blocks.filter(isTaskContainer).map((b) => ({
49
+ label:
50
+ b.level === 'module' ? `${byId.get(b.parentId ?? '')?.title ?? '?'} › ${b.title}` : b.title,
51
+ value: b.id,
52
+ }))
53
+ }
54
+
55
+ /**
56
+ * The container a surface should hold as the board changes underneath it: the current selection
57
+ * while it is still on offer, else the first one that is.
58
+ *
59
+ * Needed because "where does this land" is answered once, when the surface opens, and the board is
60
+ * live: the frame it was opened from can be deleted, or a module added to it, while it sits there.
61
+ * A selection left pointing at a deleted block is the worst of the three states, because nothing
62
+ * looks wrong: the picker renders with nothing selected while the issue search under it is still
63
+ * scoped to the block that is gone, and the create lands on an id the board no longer has.
64
+ */
65
+ export function reconcileContainer(
66
+ targets: readonly ContainerTarget[],
67
+ selected: string | undefined,
68
+ ): string | undefined {
69
+ if (selected && targets.some((t) => t.value === selected)) return selected
70
+ return targets[0]?.value
71
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ buildConnectionSourceChoices,
4
+ buildSourceChoices,
5
+ connectableSources,
6
+ menuIsPickable,
7
+ reconcileSource,
8
+ sourceMenuItems,
9
+ } from './sourcePicker'
10
+ import type { TaskSourceState } from '~/types/domain'
11
+
12
+ /**
13
+ * The pure source-selection behind `<ContextIssuePicker>`, `<BugHuntModal>` and
14
+ * `<ContextDocumentPicker>`. Pins what the always-visible selector promises: the source in use is
15
+ * named even when it is the only one, a source the workspace hasn't got yet is offered as something
16
+ * to ADD (worded for its actual state), a menu with nothing to decide is not dressed as a control,
17
+ * and the selection stays valid as the offered set changes underneath it.
18
+ */
19
+ const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
20
+ ({
21
+ source,
22
+ label: source.toUpperCase(),
23
+ icon: `i-lucide-${source}`,
24
+ available,
25
+ enabled,
26
+ credentialFields: [],
27
+ refLabel: '',
28
+ refPlaceholder: '',
29
+ }) as unknown as TaskSourceState
30
+
31
+ describe('buildSourceChoices', () => {
32
+ it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
33
+ const groups = buildSourceChoices([state('github')], 'github')
34
+ expect(groups).toEqual([
35
+ [
36
+ {
37
+ action: 'select',
38
+ source: 'github',
39
+ label: 'GITHUB',
40
+ icon: 'i-lucide-github',
41
+ active: true,
42
+ },
43
+ ],
44
+ ])
45
+ })
46
+
47
+ it('marks only the selected tracker active', () => {
48
+ const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
49
+ expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
50
+ ['github', false],
51
+ ['jira', true],
52
+ ])
53
+ })
54
+
55
+ it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
56
+ const [, addable] = buildSourceChoices(
57
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
58
+ 'github',
59
+ )
60
+ expect(addable).toEqual([
61
+ { action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
62
+ { action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
63
+ ])
64
+ })
65
+
66
+ // What a surface's "nothing connected yet" state renders its add buttons from: it flattens
67
+ // the groups, so every choice there has to be addable. A `select` leaking through would
68
+ // offer to connect a tracker the workspace already has.
69
+ it('yields only addable choices when the workspace offers no tracker', () => {
70
+ const choices = buildSourceChoices(
71
+ [state('jira', { available: false }), state('linear', { enabled: false })],
72
+ undefined,
73
+ ).flat()
74
+ expect(choices.map((c) => c.action)).toEqual(['connect', 'enable'])
75
+ })
76
+
77
+ it('drops empty groups so the menu renders no stray separator', () => {
78
+ expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
79
+ expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
80
+ expect(buildSourceChoices([], undefined)).toEqual([])
81
+ })
82
+ })
83
+
84
+ describe('connectableSources', () => {
85
+ const sources = [
86
+ { source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
87
+ { source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
88
+ ]
89
+ const isConnected = (source: string) => source === 'github'
90
+
91
+ it('offers only the sources the workspace has not connected', () => {
92
+ const rows = connectableSources(sources, { isConnected, canConnect: true, available: true })
93
+ expect(rows.map((s) => s.source)).toEqual(['confluence'])
94
+ })
95
+
96
+ // Connecting stores a workspace credential and stays admin-tier while attaching moved to the
97
+ // member tier, so a member's add tier is withheld rather than rendered into a 403.
98
+ it('withholds every source from a user who may not connect one', () => {
99
+ expect(
100
+ connectableSources(sources, { isConnected, canConnect: false, available: true }),
101
+ ).toEqual([])
102
+ })
103
+
104
+ // The term the three former copies of this rule disagreed about. An integration the deployment
105
+ // has not configured has nothing to connect TO, so it is not the same fact as "already connected"
106
+ // and must not rest on the store happening to clear its source list.
107
+ it('withholds every source when the integration is unavailable to the deployment', () => {
108
+ expect(
109
+ connectableSources(sources, { isConnected: () => false, canConnect: true, available: false }),
110
+ ).toEqual([])
111
+ })
112
+ })
113
+
114
+ describe('buildConnectionSourceChoices', () => {
115
+ const sources = [
116
+ { source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
117
+ { source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
118
+ ]
119
+ const isConnected = (source: string) => source === 'github'
120
+ const opts = { isConnected, canConnect: true, available: true, selected: 'github' }
121
+
122
+ it('offers the connected source and the rest as something to add', () => {
123
+ expect(buildConnectionSourceChoices(sources, opts)).toEqual([
124
+ [
125
+ {
126
+ action: 'select',
127
+ source: 'github',
128
+ label: 'GitHub',
129
+ icon: 'i-lucide-github',
130
+ active: true,
131
+ },
132
+ ],
133
+ [
134
+ {
135
+ action: 'connect',
136
+ source: 'confluence',
137
+ label: 'Confluence',
138
+ icon: 'i-lucide-file-text',
139
+ },
140
+ ],
141
+ ])
142
+ })
143
+
144
+ // No per-workspace toggle exists for these sources, so `enable` ("connected but switched off
145
+ // here") is a state they cannot be in. It is unrepresentable in the return TYPE, which is what
146
+ // stops a document surface wording an add entry as "Connect X" for a source already connected;
147
+ // this asserts the runtime half of that.
148
+ it('never words a document source as `enable`', () => {
149
+ const choices = buildConnectionSourceChoices(sources, {
150
+ ...opts,
151
+ isConnected: () => false,
152
+ selected: undefined,
153
+ })
154
+ expect(
155
+ choices
156
+ .flat()
157
+ .map((c) => c.action)
158
+ .every((action) => action === 'connect'),
159
+ ).toBe(true)
160
+ })
161
+
162
+ it('yields the connected tier alone for a member, with no add group', () => {
163
+ const choices = buildConnectionSourceChoices(sources, { ...opts, canConnect: false })
164
+ expect(choices).toHaveLength(1)
165
+ expect(choices[0]!.map((c) => c.source)).toEqual(['github'])
166
+ })
167
+ })
168
+
169
+ describe('menuIsPickable', () => {
170
+ // The papercut this exists for, and the one a member hits: a chevron opening a menu whose single
171
+ // entry re-selects what is already selected promises a choice that isn't there.
172
+ it('reports nothing to decide for a lone entry', () => {
173
+ expect(menuIsPickable([[{}]])).toBe(false)
174
+ expect(menuIsPickable([])).toBe(false)
175
+ })
176
+
177
+ it('counts across groups, so one source plus one to add IS a choice', () => {
178
+ expect(menuIsPickable([[{}], [{}]])).toBe(true)
179
+ expect(menuIsPickable([[{}, {}]])).toBe(true)
180
+ })
181
+ })
182
+
183
+ describe('sourceMenuItems', () => {
184
+ const onSelect = vi.fn()
185
+ const onAdd = vi.fn()
186
+
187
+ it('marks the selected source as CHECKED, not merely glyphed', () => {
188
+ const [offered] = sourceMenuItems(
189
+ buildSourceChoices([state('github'), state('jira')], 'jira'),
190
+ {
191
+ onSelect,
192
+ onAdd,
193
+ addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
194
+ },
195
+ )
196
+ expect(offered!.map((i) => [i.label, i.type, i.checked])).toEqual([
197
+ ['GITHUB', 'checkbox', false],
198
+ ['JIRA', 'checkbox', true],
199
+ ])
200
+ })
201
+
202
+ it('words each add entry for its own action and marks it with the plug icon', () => {
203
+ const [, addable] = sourceMenuItems(
204
+ buildSourceChoices(
205
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
206
+ 'github',
207
+ ),
208
+ {
209
+ onSelect,
210
+ onAdd,
211
+ addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
212
+ },
213
+ )
214
+ expect(addable!.map((i) => [i.label, i.icon])).toEqual([
215
+ ['connect JIRA', 'i-lucide-plug'],
216
+ ['enable LINEAR', 'i-lucide-plug'],
217
+ ])
218
+ })
219
+
220
+ // A connection-only menu owes ONE wording, and the type says so: passing `enable` here would not
221
+ // compile. That is the guard, and this pins the behaviour it guards.
222
+ it('takes only the `connect` wording for a connection-only menu', () => {
223
+ const items = sourceMenuItems(
224
+ buildConnectionSourceChoices(
225
+ [{ source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' }],
226
+ { isConnected: () => false, canConnect: true, available: true, selected: undefined },
227
+ ),
228
+ { onSelect, onAdd, addLabel: { connect: (l) => `Connect ${l}` } },
229
+ )
230
+ expect(items.flat().map((i) => i.label)).toEqual(['Connect Confluence'])
231
+ })
232
+ })
233
+
234
+ describe('reconcileSource', () => {
235
+ it('selects the tracker the user just went off to connect, once it is offered', () => {
236
+ expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
237
+ })
238
+
239
+ it('keeps the current selection while the connect is still pending', () => {
240
+ expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
241
+ })
242
+
243
+ it('keeps a still-offered selection', () => {
244
+ expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
245
+ })
246
+
247
+ it('falls back to the first offered tracker when the selection stopped being offered', () => {
248
+ expect(reconcileSource(['github'], 'jira', null)).toBe('github')
249
+ })
250
+
251
+ it('selects the first offered tracker when nothing is selected yet', () => {
252
+ expect(reconcileSource(['github'], undefined, null)).toBe('github')
253
+ })
254
+
255
+ it('resolves to nothing when the workspace offers no tracker at all', () => {
256
+ expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
257
+ })
258
+ })