@cat-factory/app 0.277.1 → 0.278.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.
Files changed (35) hide show
  1. package/README.md +46 -10
  2. package/app/components/board/nodes/TaskCard.vue +5 -4
  3. package/app/components/board/nodes/TaskLane.vue +1 -2
  4. package/app/components/providers/ApiKeysSection.vue +11 -1
  5. package/app/components/providers/VendorCredentialsModal.vue +1 -1
  6. package/app/composables/api/workspaces.ts +4 -2
  7. package/app/composables/useFrameLanes.ts +35 -32
  8. package/app/composables/useWorkspaceStream.ts +15 -29
  9. package/app/composables/workspaceStream/coarseRefresh.spec.ts +146 -0
  10. package/app/composables/workspaceStream/coarseRefresh.ts +121 -0
  11. package/app/stores/environmentTest.spec.ts +33 -0
  12. package/app/stores/environmentTest.ts +24 -0
  13. package/app/stores/execution.spec.ts +50 -0
  14. package/app/stores/execution.ts +30 -7
  15. package/app/stores/notifications.ts +16 -0
  16. package/app/stores/pipelines.ts +9 -2
  17. package/app/stores/recurringPipelines.ts +15 -1
  18. package/app/stores/workspace/refreshFunnel.spec.ts +242 -0
  19. package/app/stores/workspace/refreshFunnel.ts +156 -0
  20. package/app/stores/workspace.spec.ts +30 -24
  21. package/app/stores/workspace.ts +17 -26
  22. package/app/utils/laneIdentity.spec.ts +119 -0
  23. package/app/utils/laneIdentity.ts +102 -0
  24. package/app/utils/laneSort.ts +35 -3
  25. package/i18n/locales/de.json +7 -2
  26. package/i18n/locales/en.json +7 -2
  27. package/i18n/locales/es.json +7 -2
  28. package/i18n/locales/fr.json +7 -2
  29. package/i18n/locales/he.json +7 -2
  30. package/i18n/locales/it.json +7 -2
  31. package/i18n/locales/ja.json +7 -2
  32. package/i18n/locales/pl.json +7 -2
  33. package/i18n/locales/tr.json +7 -2
  34. package/i18n/locales/uk.json +7 -2
  35. package/package.json +2 -2
@@ -45,12 +45,17 @@ beforeEach(() => {
45
45
 
46
46
  // Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
47
47
  // resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
48
- // refreshes can be in flight at once (events >300ms apart, or a resync + a board event), and if a
49
- // slower/staler fetch resolves AFTER a newer one, its hydrate used to clobber the newer state
50
- // dropping a just-spawned block whose only live delivery was the coarse board event, so its card
51
- // never reappeared (no further event to restore it). This surfaced as an intermittent e2e timeout
52
- // where a spawned task/document card never rendered. `refresh()` now stamps each call so only the
53
- // latest-issued one commits; this test pins that a stale out-of-order refresh can't win.
48
+ // refreshes used to be in flight at once (events >300ms apart, or a resync + a board event), and a
49
+ // slower/staler fetch resolving AFTER a newer one clobbered the newer state, dropping a
50
+ // just-spawned block whose only live delivery was the coarse board event, so its card never
51
+ // reappeared (no further event to restore it). That surfaced as an intermittent e2e timeout where a
52
+ // spawned task/document card never rendered.
53
+ //
54
+ // `refresh()` now goes through the funnel, which SERIALIZES: a call arriving during a fetch waits
55
+ // for a follow-up fetch issued after it rather than racing beside it. So the out-of-order clobber
56
+ // is structurally unreachable rather than detected and discarded, and what this test pins is the
57
+ // property that replaced the sequence stamp: overlapping refreshes never interleave, and the board
58
+ // ends on the LAST snapshot fetched. `refreshFunnel.spec.ts` covers the coalescing rules directly.
54
59
 
55
60
  /** Minimal block — only the fields the board store's index getters read. */
56
61
  function block(id: string, over: Partial<Block> = {}): Block {
@@ -102,20 +107,19 @@ function snapshot(
102
107
  }
103
108
 
104
109
  describe('workspace store refresh ordering', () => {
105
- it('a stale refresh resolving out of order does not clobber a newer one', async () => {
110
+ it('serializes overlapping refreshes so the board ends on the last snapshot fetched', async () => {
106
111
  const frame = block('f1')
107
112
  const spawned = block('spawned', { level: 'task', parentId: 'f1' })
108
- let resolveStale!: (s: WorkspaceSnapshot) => void
109
- let resolveFresh!: (s: WorkspaceSnapshot) => void
113
+ let resolveFirst!: (s: WorkspaceSnapshot) => void
110
114
 
111
115
  const getWorkspace = vi
112
116
  .fn()
113
117
  // 1) switchTo establishes the active board (no spawned block yet).
114
118
  .mockResolvedValueOnce(snapshot('ws1', [frame]))
115
- // 2) the EARLIER-issued refresh (stale: still no spawned block) resolved LAST below.
116
- .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveStale = r)))
117
- // 3) the LATER-issued refresh (fresh: the spawned block landed) resolved FIRST below.
118
- .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFresh = r)))
119
+ // 2) the first refresh's fetch: still no spawned block, and held open below.
120
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFirst = r)))
121
+ // 3) the follow-up fetch, ISSUED ONLY AFTER (2) settles, which is why it sees the spawn.
122
+ .mockResolvedValueOnce(snapshot('ws1', [frame, spawned]))
119
123
  vi.stubGlobal('useApi', () => ({ getWorkspace }))
120
124
 
121
125
  const ws = useWorkspaceStore()
@@ -123,21 +127,23 @@ describe('workspace store refresh ordering', () => {
123
127
  await ws.switchTo('ws1')
124
128
  expect(board.getBlock('spawned')).toBeUndefined()
125
129
 
126
- // Two overlapping refreshes: the later-issued one carries the fresher snapshot, but the
127
- // earlier-issued (stale) one resolves last — the exact out-of-order clobber the guard blocks.
128
- const stalePass = ws.refresh()
129
- const freshPass = ws.refresh()
130
- resolveFresh(snapshot('ws1', [frame, spawned]))
131
- resolveStale(snapshot('ws1', [frame]))
132
- await Promise.all([stalePass, freshPass])
130
+ // A second refresh arriving mid-fetch does NOT start a racing fetch: one is outstanding.
131
+ const first = ws.refresh()
132
+ const second = ws.refresh()
133
+ expect(getWorkspace).toHaveBeenCalledTimes(2)
133
134
 
134
- // The fresh snapshot won and the stale one was discarded: the spawned card survives.
135
+ resolveFirst(snapshot('ws1', [frame]))
136
+ await Promise.all([first, second])
137
+
138
+ // The follow-up ran after the first settled and its snapshot is what the board holds, so a
139
+ // caller that refreshed after spawning the block sees it.
140
+ expect(getWorkspace).toHaveBeenCalledTimes(3)
135
141
  expect(board.getBlock('spawned')).toBeDefined()
136
142
  })
137
143
 
138
- // Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. The
139
- // `refreshSeq` guard above only orders refreshes against each OTHER it does nothing when a
140
- // single refresh's (slow) fetch overlaps a targeted live event. A run's status transitions
144
+ // Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. Serializing
145
+ // refreshes orders them against each OTHER and does nothing when a single refresh's (slow) fetch
146
+ // overlaps a targeted live event. A run's status transitions
141
147
  // (…→ in_progress → pr_ready/done) arrive as `execution`-event `board.upsert`s; a refresh whose
142
148
  // snapshot was FETCHED while the block was still `in_progress` must not, on resolving later,
143
149
  // replace that block back to the stale status. This was the reliable-under-CI-latency e2e
@@ -14,6 +14,7 @@ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
14
14
  import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
15
15
  import { createWorkspaceCommands } from '~/stores/workspace/commands'
16
16
  import { createInfraSetupState } from '~/stores/workspace/infraSetup'
17
+ import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
17
18
  import { markBoot } from '~/utils/bootMarks'
18
19
  import { retryWhileBackendUnreachable } from '~/utils/backendReady'
19
20
 
@@ -198,34 +199,22 @@ export const useWorkspaceStore = defineStore(
198
199
  resolveActiveBoard,
199
200
  })
200
201
 
201
- // Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
202
- // each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
203
- // ordering, two in-flight fetches can resolve out of order, so a slower/staler snapshot's
204
- // hydrate clobbers a newer one dropping a just-spawned block whose ONLY live delivery was
205
- // the coarse `board` event (there is no per-block push), so its card never reappears (no
206
- // further event to restore it). Stamping each call lets only the latest-issued refresh commit.
207
- let refreshSeq = 0
208
-
209
- /** Re-fetch the snapshot and re-hydrate (after mutations and on stream (re)connect). */
210
- async function refresh() {
211
- const targetId = workspaceId.value
212
- if (!targetId) return
213
- const seq = ++refreshSeq
214
- // Capture the live-write baselines BEFORE the fetch: anything a live event writes while
215
- // this (potentially slow) snapshot is in flight is newer than the snapshot, so `hydrate`
216
- // must NOT clobber it back. The `refreshSeq` guard below only orders refreshes against each
217
- // OTHER — this guards a refresh against an interleaved live write (a run's terminal status
218
- // landing mid-fetch, or the inbox card it raises), the coherence hazard under CI latency.
219
- const baselines: LiveWriteBaselines = {
202
+ // The one door every full-snapshot refresh goes through: it coalesces the ~35 direct
203
+ // post-mutation call sites and the stream's coarse-event resync into at most one in-flight
204
+ // fetch plus one queued follow-up, and exposes the coverage mark the stream's debounce uses to
205
+ // drop a resync a mutation's own refresh already served. Ordering (a stale snapshot's hydrate
206
+ // clobbering a newer one, which would drop a just-spawned block whose ONLY live delivery was
207
+ // the coarse `board` event) falls out of there being one fetch at a time. Rules and the
208
+ // reasoning: `stores/workspace/refreshFunnel.ts`.
209
+ const { refresh, refreshMark, hydratedSince } = createRefreshFunnel({
210
+ currentWorkspaceId: () => workspaceId.value,
211
+ fetchSnapshot: (id, signal) => api.getWorkspace(id, signal),
212
+ captureBaselines: (): LiveWriteBaselines => ({
220
213
  board: useBoardStore().hydrateBaseline(),
221
214
  notifications: useNotificationsStore().hydrateBaseline(),
222
- }
223
- const snapshot = await api.getWorkspace(targetId)
224
- // A newer refresh was issued (or the active board switched) while this fetch was in flight —
225
- // discard this older/staler result so it can't clobber the newer hydrate.
226
- if (seq !== refreshSeq || workspaceId.value !== targetId) return
227
- hydrate(snapshot, baselines)
228
- }
215
+ }),
216
+ apply: hydrate,
217
+ })
229
218
 
230
219
  /** The active workspace id, or throw if the app isn't bootstrapped yet. */
231
220
  function requireId(): string {
@@ -262,6 +251,8 @@ export const useWorkspaceStore = defineStore(
262
251
  rename,
263
252
  remove,
264
253
  refresh,
254
+ refreshMark,
255
+ hydratedSince,
265
256
  requireId,
266
257
  resumeSpend,
267
258
  }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createLaneMemo } from '~/utils/laneIdentity'
3
+ import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
4
+ import type { Block } from '~/types/domain'
5
+
6
+ const taskA = { id: 'a', title: 'A' } as unknown as Block
7
+ const taskB = { id: 'b', title: 'B' } as unknown as Block
8
+
9
+ function entry(task: Block, over: Partial<LaneTaskEntry> = {}): LaneTaskEntry {
10
+ return {
11
+ task,
12
+ reason: 'running',
13
+ order: 0,
14
+ activityAt: null,
15
+ waitingSince: null,
16
+ moduleName: null,
17
+ initiativeName: null,
18
+ epicName: null,
19
+ ...over,
20
+ } as LaneTaskEntry
21
+ }
22
+
23
+ function group(id: string | null, entries: LaneTaskEntry[]): LaneGroup {
24
+ return { id, label: id, entries }
25
+ }
26
+
27
+ function lanes(...groups: LaneGroup[][]): RenderedLane[] {
28
+ const names = ['not_started', 'in_progress', 'needs_you', 'done'] as const
29
+ return groups.map((g, i) => ({
30
+ lane: names[i]!,
31
+ groups: g,
32
+ total: g.reduce((n, x) => n + x.entries.length, 0),
33
+ }))
34
+ }
35
+
36
+ describe('lane structural sharing', () => {
37
+ it('returns the previous array when nothing changed', () => {
38
+ const share = createLaneMemo()
39
+ const first = share(lanes([group('g1', [entry(taskA)])]))
40
+ const second = share(lanes([group('g1', [entry(taskA)])]))
41
+ expect(second).toBe(first)
42
+ expect(second[0]).toBe(first[0])
43
+ expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
44
+ expect(second[0]!.groups[0]!.entries[0]).toBe(first[0]!.groups[0]!.entries[0])
45
+ })
46
+
47
+ it('keeps the untouched lane identical while replacing the one that changed', () => {
48
+ const share = createLaneMemo()
49
+ const first = share(lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB)])]))
50
+ const second = share(
51
+ lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB, { activityAt: 5 })])]),
52
+ )
53
+ expect(second).not.toBe(first)
54
+ expect(second[0]).toBe(first[0])
55
+ expect(second[1]).not.toBe(first[1])
56
+ })
57
+
58
+ // Half of the reference rule: a REPLACED block (what every `board.upsert` does) is a new object,
59
+ // so reuse cannot hand a renderer the pre-write one.
60
+ it('does not reuse an entry whose task object was replaced', () => {
61
+ const share = createLaneMemo()
62
+ const first = share(lanes([group('g1', [entry(taskA)])]))
63
+ const renamed = { id: 'a', title: 'A renamed' } as unknown as Block
64
+ const second = share(lanes([group('g1', [entry(renamed)])]))
65
+ expect(second).not.toBe(first)
66
+ expect(second[0]!.groups[0]!.entries[0]!.task).toBe(renamed)
67
+ })
68
+
69
+ /**
70
+ * The other half, and the case the comparison deliberately does NOT try to detect:
71
+ * `stores/board/placement.ts` patches a block IN PLACE for its optimistic writes, so both entries
72
+ * hold the same object and no comparison over it could see the difference. Reuse is still
73
+ * observationally identical, because the object a renderer reads through IS the patched one (and
74
+ * `board.blocks` is deeply reactive, so the patch invalidates whatever read it). What must not be
75
+ * reused is an entry whose DERIVED fields moved with the patch, which is the assertion below.
76
+ */
77
+ it('reuses an entry whose task was patched in place, but not its derived fields', () => {
78
+ const share = createLaneMemo()
79
+ const task = { id: 'a', title: 'A', moduleName: null } as unknown as Block
80
+ const first = share(lanes([group('g1', [entry(task)])]))
81
+ Object.assign(task, { title: 'A patched', moduleName: 'billing' })
82
+ // Same derived fields: the entry is reused, and it carries the patched object.
83
+ const unchanged = share(lanes([group('g1', [entry(task)])]))
84
+ expect(unchanged).toBe(first)
85
+ expect(unchanged[0]!.groups[0]!.entries[0]!.task.title).toBe('A patched')
86
+ // The assembly re-derived `moduleName` from the patch: that is a fresh entry.
87
+ const rederived = share(lanes([group('g1', [entry(task, { moduleName: 'billing' })])]))
88
+ expect(rederived).not.toBe(first)
89
+ expect(rederived[0]!.groups[0]!.entries[0]!.moduleName).toBe('billing')
90
+ })
91
+
92
+ it('treats reordering, additions and removals as changes', () => {
93
+ const share = createLaneMemo()
94
+ const first = share(lanes([group('g1', [entry(taskA), entry(taskB)])]))
95
+ expect(share(lanes([group('g1', [entry(taskB), entry(taskA)])]))).not.toBe(first)
96
+
97
+ const share2 = createLaneMemo()
98
+ const base = share2(lanes([group('g1', [entry(taskA)])]))
99
+ expect(share2(lanes([group('g1', [entry(taskA), entry(taskB)])]))).not.toBe(base)
100
+ })
101
+
102
+ it('reuses an unchanged group while the sibling group in the same lane changes', () => {
103
+ const share = createLaneMemo()
104
+ const first = share(lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB)])]))
105
+ const second = share(
106
+ lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB, { order: 3 })])]),
107
+ )
108
+ expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
109
+ expect(second[0]!.groups[1]).not.toBe(first[0]!.groups[1])
110
+ })
111
+
112
+ it('does not reuse a lane whose total changed even when its rendered groups match', () => {
113
+ const share = createLaneMemo()
114
+ const first = share(lanes([group('g1', [entry(taskA)])]))
115
+ const capped = lanes([group('g1', [entry(taskA)])])
116
+ capped[0] = { ...capped[0]!, total: 99 }
117
+ expect(share(capped)).not.toBe(first)
118
+ })
119
+ })
@@ -0,0 +1,102 @@
1
+ import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Structural sharing for the swimlane output.
5
+ //
6
+ // WHY. The lane assembly (`useFrameLanes`) is a chain of computeds over the board blocks, both
7
+ // pending-gate indexes, the coarse agent-run summary and the open notifications. Any ONE execution
8
+ // event invalidates the whole chain for EVERY mounted frame, and the chain rebuilds every entry,
9
+ // every group and every lane as fresh objects. Vue then sees new identities all the way down, so
10
+ // `TaskLane` / `LaneGroup` re-render and every card diff re-runs, even for the (common) case where
11
+ // the event changed one run's progress and moved nothing.
12
+ //
13
+ // WHAT THIS DOES. Compare the freshly built value against the previous one field by field and hand
14
+ // BACK the previous object wherever nothing changed, so `===` holds for the parts that did not
15
+ // move. Reuse cascades: unchanged entries let a group be reused, unchanged groups let a lane be
16
+ // reused, unchanged lanes let the whole array be reused.
17
+ //
18
+ // THE CORRECTNESS RULE. Reuse is only sound when the reused object is observationally identical to
19
+ // the fresh one, so the comparison must cover every DERIVED field: a value this assembly computed
20
+ // (`moduleName`, `initiativeName`, `epicName`, the activity/wait stamps) exists only on the entry,
21
+ // so a reused entry carrying a stale one is a lie nothing else can correct. That is why
22
+ // {@link sameEntry} destructures rather than looping over keys, and why a field added to
23
+ // `LaneTaskEntry` must be added to it in the same change.
24
+ //
25
+ // `task` is compared by REFERENCE, and that is NOT because the board store always replaces the
26
+ // object: `stores/board/placement.ts` patches a block IN PLACE for its optimistic drag, reparent and
27
+ // field edits, so the previous entry and the fresh one hold the very same object. Reference equality
28
+ // is still the right comparison, for a different reason on each side. A REPLACED block (every
29
+ // `board.upsert`, i.e. every server-sourced write) is a new reference, so it is caught here. A block
30
+ // patched IN PLACE is one object that both entries share, so a renderer reading through the reused
31
+ // entry reads the new value, and `board.blocks` is deeply reactive, so the mutation itself
32
+ // invalidates whatever read it. What the mutation cannot reach is a value DERIVED off it, which is
33
+ // exactly what the field comparison above covers.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function sameEntry(a: LaneTaskEntry, b: LaneTaskEntry): boolean {
37
+ return (
38
+ a.task === b.task &&
39
+ a.reason === b.reason &&
40
+ a.order === b.order &&
41
+ a.activityAt === b.activityAt &&
42
+ a.waitingSince === b.waitingSince &&
43
+ a.moduleName === b.moduleName &&
44
+ a.initiativeName === b.initiativeName &&
45
+ a.epicName === b.epicName
46
+ )
47
+ }
48
+
49
+ /** Element-wise reuse: the previous array when every member matched, else the fresh one. */
50
+ function shareList<T>(
51
+ previous: readonly T[] | undefined,
52
+ next: T[],
53
+ same: (a: T, b: T) => boolean,
54
+ reuse?: (previous: T, next: T) => T,
55
+ ): T[] {
56
+ if (!previous || previous.length !== next.length) return next
57
+ let changed = false
58
+ const shared = next.map((item, i) => {
59
+ const prior = previous[i]!
60
+ if (same(prior, item)) return prior
61
+ const merged = reuse ? reuse(prior, item) : item
62
+ if (merged !== prior) changed = true
63
+ return merged
64
+ })
65
+ return changed ? shared : (previous as T[])
66
+ }
67
+
68
+ function sameGroup(a: LaneGroup, b: LaneGroup): boolean {
69
+ return a.id === b.id && a.label === b.label && a.entries === b.entries
70
+ }
71
+
72
+ function reuseGroup(previous: LaneGroup, next: LaneGroup): LaneGroup {
73
+ if (previous.id !== next.id || previous.label !== next.label) return next
74
+ const entries = shareList(previous.entries, next.entries, sameEntry)
75
+ return entries === previous.entries ? previous : { ...next, entries }
76
+ }
77
+
78
+ function sameLane(a: RenderedLane, b: RenderedLane): boolean {
79
+ return a.lane === b.lane && a.total === b.total && a.groups === b.groups
80
+ }
81
+
82
+ function reuseLane(previous: RenderedLane, next: RenderedLane): RenderedLane {
83
+ if (previous.lane !== next.lane || previous.total !== next.total) return next
84
+ const groups = shareList(previous.groups, next.groups, sameGroup, reuseGroup)
85
+ return groups === previous.groups ? previous : { ...next, groups }
86
+ }
87
+
88
+ /**
89
+ * A per-frame memo that hands back the previously rendered lanes wherever the freshly assembled
90
+ * ones are identical.
91
+ *
92
+ * Holds ONE generation, not a cache: the previous result is the only thing a recompute can share
93
+ * with, so there is nothing to evict and nothing to bound. Created per `useFrameLanes` instance, so
94
+ * it lives and dies with the frame that owns it.
95
+ */
96
+ export function createLaneMemo(): (next: RenderedLane[]) => RenderedLane[] {
97
+ let previous: RenderedLane[] | undefined
98
+ return (next) => {
99
+ previous = shareList(previous, next, sameLane, reuseLane)
100
+ return previous
101
+ }
102
+ }
@@ -179,8 +179,26 @@ const NEEDS_YOU_TIER: Partial<Record<LaneReason, number>> = {
179
179
 
180
180
  type Comparator = (a: LaneTaskEntry, b: LaneTaskEntry) => number
181
181
 
182
+ /**
183
+ * One collator for every text comparison in this module, built lazily and reused.
184
+ *
185
+ * `String.prototype.localeCompare` constructs a fresh collator per CALL, and these comparators run
186
+ * O(n log n) times per lane per frame on every board event: the `task_type` comparator alone made
187
+ * two of those calls per comparison. Constructing the collator once turns the dominant cost of a
188
+ * text sort back into the comparison itself.
189
+ *
190
+ * Built with NO options, so it is the same collation `a.localeCompare(b)` performed: this is a
191
+ * caching change, and a lane whose order shifted under it would be a behaviour change nobody asked
192
+ * for wearing a performance change's clothes.
193
+ */
194
+ let collator: Intl.Collator | undefined
195
+ function compareText(a: string, b: string): number {
196
+ collator ??= new Intl.Collator()
197
+ return collator.compare(a, b)
198
+ }
199
+
182
200
  const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> = {
183
- title: (a, b) => a.task.title.localeCompare(b.task.title),
201
+ title: (a, b) => compareText(a.task.title, b.task.title),
184
202
  oldest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
185
203
  newest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, descending),
186
204
  longest_wait: (a, b) => nullsLast(a.waitingSince, b.waitingSince, ascending),
@@ -188,8 +206,8 @@ const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> =
188
206
  impact_desc: (a, b) =>
189
207
  nullsLast(a.task.estimate?.impact ?? null, b.task.estimate?.impact ?? null, descending),
190
208
  task_type: (a, b) =>
191
- (a.task.taskType ?? '').localeCompare(b.task.taskType ?? '') ||
192
- a.task.title.localeCompare(b.task.title),
209
+ compareText(a.task.taskType ?? '', b.task.taskType ?? '') ||
210
+ compareText(a.task.title, b.task.title),
193
211
  }
194
212
 
195
213
  /**
@@ -242,6 +260,20 @@ export interface LaneGroup {
242
260
  readonly entries: LaneTaskEntry[]
243
261
  }
244
262
 
263
+ /**
264
+ * One rendered lane: its identity, its groups, and the count its header states.
265
+ *
266
+ * Lives beside the shapes it composes rather than in `composables/useFrameLanes`, which assembles
267
+ * it: `utils/laneIdentity.ts` compares these, and a util reaching into a composable for its central
268
+ * type points the dependency the wrong way round.
269
+ */
270
+ export interface RenderedLane {
271
+ readonly lane: TaskLane
272
+ readonly groups: LaneGroup[]
273
+ /** Every task classified into this lane, BEFORE the Done lane's caps. */
274
+ readonly total: number
275
+ }
276
+
245
277
  /**
246
278
  * The value a task is grouped under, or null for the catch-all group.
247
279
  *
@@ -3426,8 +3426,8 @@
3426
3426
  "proxyHeading": "Proxy-/Gateway-API-Schlüssel",
3427
3427
  "directAccountIntro": "Verbinden Sie einen Vendor-API-Schlüssel, der von jedem Workspace in diesem Account geteilt wird. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Account-Schlüssel werden vom Administrator verwaltet.",
3428
3428
  "directIntro": "Verbinden Sie einen Vendor-API-Schlüssel, damit Modelle direkt bei diesem Provider laufen. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
3429
- "proxyAccountIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, LiteLLM), der von jedem Workspace in diesem Account geteilt wird. Ein Proxy ist ein Vermittler, der viele Vendors hinter einem einzigen Schlüssel bündelt. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert.",
3430
- "proxyIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, LiteLLM). Ein Proxy ist ein Vermittler, der die Modelle vieler Vendors über ein einziges Gateway erreicht, statt direkt bei einem Vendor. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
3429
+ "proxyAccountIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, Bifrost, LiteLLM), der von jedem Workspace in diesem Account geteilt wird. Ein Proxy ist ein Vermittler, der viele Vendors hinter einem einzigen Schlüssel bündelt. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert.",
3430
+ "proxyIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, Bifrost, LiteLLM). Ein Proxy ist ein Vermittler, der die Modelle vieler Vendors über ein einziges Gateway erreicht, statt direkt bei einem Vendor. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
3431
3431
  "scopeField": "Geltungsbereich",
3432
3432
  "scopeWorkspace": "Dieser Workspace",
3433
3433
  "scopeUser": "Meine Schlüssel (nur ich)",
@@ -3485,6 +3485,11 @@
3485
3485
  "step1": "Öffnen Sie openrouter.ai → Keys und erstellen Sie einen API-Schlüssel.",
3486
3486
  "step2": "Kopieren Sie den Schlüssel (beginnt mit sk-or-…); er erreicht über ein Gateway mehr als 300 Modelle."
3487
3487
  },
3488
+ "bifrost": {
3489
+ "label": "Bifrost (selbst gehostetes Gateway)",
3490
+ "step1": "Erstellen Sie einen virtuellen Schlüssel in den Governance-Einstellungen Ihres Bifrost-Gateways (oder verwenden Sie dessen API-Schlüssel).",
3491
+ "step2": "Die Basis-URL des Gateways wird von Ihrem Deployment-Betreiber festgelegt (BIFROST_BASE_URL), nicht hier."
3492
+ },
3488
3493
  "litellm": {
3489
3494
  "label": "LiteLLM (selbst gehostetes Gateway)",
3490
3495
  "step1": "Erstellen Sie einen virtuellen Schlüssel auf Ihrem LiteLLM-Gateway (oder verwenden Sie dessen Master Key).",
@@ -4334,8 +4334,8 @@
4334
4334
  "proxyHeading": "Proxy / gateway API keys",
4335
4335
  "directAccountIntro": "Connect a vendor API key shared by every workspace in this account. Keys are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.",
4336
4336
  "directIntro": "Connect a vendor API key so models run directly on that provider. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
4337
- "proxyAccountIntro": "Connect a proxy key (OpenRouter, LiteLLM) shared by every workspace in this account. A proxy is an intermediary that fronts many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.",
4338
- "proxyIntro": "Connect a proxy key (OpenRouter, LiteLLM). A proxy is an intermediary that reaches many vendors' models through a single gateway, rather than one vendor directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
4337
+ "proxyAccountIntro": "Connect a proxy key (OpenRouter, Bifrost, LiteLLM) shared by every workspace in this account. A proxy is an intermediary that fronts many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.",
4338
+ "proxyIntro": "Connect a proxy key (OpenRouter, Bifrost, LiteLLM). A proxy is an intermediary that reaches many vendors' models through a single gateway, rather than one vendor directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
4339
4339
  "scopeField": "Scope",
4340
4340
  "scopeWorkspace": "This workspace",
4341
4341
  "scopeUser": "My keys (only me)",
@@ -4396,6 +4396,11 @@
4396
4396
  "step1": "Open openrouter.ai → Keys and create an API key.",
4397
4397
  "step2": "Copy the key (starts with sk-or-…); it reaches 300+ models through one gateway."
4398
4398
  },
4399
+ "bifrost": {
4400
+ "label": "Bifrost (self-hosted gateway)",
4401
+ "step1": "Create a virtual key in your Bifrost gateway's governance settings (or use its API key).",
4402
+ "step2": "The gateway's base URL is set by your deployment operator (BIFROST_BASE_URL), not here."
4403
+ },
4399
4404
  "litellm": {
4400
4405
  "label": "LiteLLM (self-hosted gateway)",
4401
4406
  "step1": "Generate a virtual key on your LiteLLM gateway (or use its master key).",
@@ -4188,8 +4188,8 @@
4188
4188
  "proxyHeading": "Claves de API de proxy / pasarela",
4189
4189
  "directAccountIntro": "Conecta una clave de API de proveedor compartida por todos los espacios de trabajo de esta cuenta. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Las claves de cuenta las gestiona un administrador.",
4190
4190
  "directIntro": "Conecta una clave de API de proveedor para que los modelos se ejecuten directamente en ese proveedor. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
4191
- "proxyAccountIntro": "Conecta una clave de proxy (OpenRouter, LiteLLM) compartida por todos los espacios de trabajo de esta cuenta. Un proxy es un intermediario que da acceso a muchos proveedores tras una sola clave. Las claves se almacenan cifradas, se agrupan y se rotan según el uso.",
4192
- "proxyIntro": "Conecta una clave de proxy (OpenRouter, LiteLLM). Un proxy es un intermediario que llega a los modelos de muchos proveedores a través de una sola pasarela, en lugar de un proveedor directo. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
4191
+ "proxyAccountIntro": "Conecta una clave de proxy (OpenRouter, Bifrost, LiteLLM) compartida por todos los espacios de trabajo de esta cuenta. Un proxy es un intermediario que da acceso a muchos proveedores tras una sola clave. Las claves se almacenan cifradas, se agrupan y se rotan según el uso.",
4192
+ "proxyIntro": "Conecta una clave de proxy (OpenRouter, Bifrost, LiteLLM). Un proxy es un intermediario que llega a los modelos de muchos proveedores a través de una sola pasarela, en lugar de un proveedor directo. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
4193
4193
  "scopeField": "Alcance",
4194
4194
  "scopeWorkspace": "Este espacio de trabajo",
4195
4195
  "scopeUser": "Mis claves (solo yo)",
@@ -4247,6 +4247,11 @@
4247
4247
  "step1": "Abre openrouter.ai → Keys y crea una clave de API.",
4248
4248
  "step2": "Copia la clave (empieza por sk-or-…); llega a más de 300 modelos a través de una sola pasarela."
4249
4249
  },
4250
+ "bifrost": {
4251
+ "label": "Bifrost (pasarela autoalojada)",
4252
+ "step1": "Genera una clave virtual en los ajustes de gobernanza de tu pasarela Bifrost (o usa su clave de API).",
4253
+ "step2": "La URL base de la pasarela la establece el operador de tu despliegue (BIFROST_BASE_URL), no aquí."
4254
+ },
4250
4255
  "litellm": {
4251
4256
  "label": "LiteLLM (pasarela autoalojada)",
4252
4257
  "step1": "Genera una clave virtual en tu pasarela LiteLLM (o usa su clave maestra).",
@@ -4188,8 +4188,8 @@
4188
4188
  "proxyHeading": "Clés d'API de proxy / passerelle",
4189
4189
  "directAccountIntro": "Connectez une clé d'API de fournisseur partagée par tous les espaces de travail de ce compte. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Les clés de compte sont gérées par un administrateur.",
4190
4190
  "directIntro": "Connectez une clé d'API de fournisseur pour que les modèles s'exécutent directement chez ce fournisseur. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
4191
- "proxyAccountIntro": "Connectez une clé de proxy (OpenRouter, LiteLLM) partagée par tous les espaces de travail de ce compte. Un proxy est un intermédiaire qui donne accès à de nombreux fournisseurs derrière une seule clé. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage.",
4192
- "proxyIntro": "Connectez une clé de proxy (OpenRouter, LiteLLM). Un proxy est un intermédiaire qui atteint les modèles de nombreux fournisseurs via une seule passerelle, plutôt qu'un fournisseur direct. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
4191
+ "proxyAccountIntro": "Connectez une clé de proxy (OpenRouter, Bifrost, LiteLLM) partagée par tous les espaces de travail de ce compte. Un proxy est un intermédiaire qui donne accès à de nombreux fournisseurs derrière une seule clé. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage.",
4192
+ "proxyIntro": "Connectez une clé de proxy (OpenRouter, Bifrost, LiteLLM). Un proxy est un intermédiaire qui atteint les modèles de nombreux fournisseurs via une seule passerelle, plutôt qu'un fournisseur direct. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
4193
4193
  "scopeField": "Portée",
4194
4194
  "scopeWorkspace": "Cet espace de travail",
4195
4195
  "scopeUser": "Mes clés (moi uniquement)",
@@ -4247,6 +4247,11 @@
4247
4247
  "step1": "Ouvrez openrouter.ai → Keys et créez une clé d'API.",
4248
4248
  "step2": "Copiez la clé (commence par sk-or-…) ; elle atteint plus de 300 modèles via une seule passerelle."
4249
4249
  },
4250
+ "bifrost": {
4251
+ "label": "Bifrost (passerelle auto-hébergée)",
4252
+ "step1": "Générez une clé virtuelle dans les paramètres de gouvernance de votre passerelle Bifrost (ou utilisez sa clé d'API).",
4253
+ "step2": "L'URL de base de la passerelle est définie par l'opérateur de votre déploiement (BIFROST_BASE_URL), pas ici."
4254
+ },
4250
4255
  "litellm": {
4251
4256
  "label": "LiteLLM (passerelle auto-hébergée)",
4252
4257
  "step1": "Générez une clé virtuelle sur votre passerelle LiteLLM (ou utilisez sa clé maîtresse).",
@@ -4188,8 +4188,8 @@
4188
4188
  "proxyHeading": "מפתחות API של פרוקסי / שער",
4189
4189
  "directAccountIntro": "חבר מפתח API של ספק המשותף לכל סביבות העבודה בחשבון זה. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. מפתחות חשבון מנוהלים על ידי מנהל.",
4190
4190
  "directIntro": "חבר מפתח API של ספק כדי שמודלים ירוצו ישירות על אותו ספק. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
4191
- "proxyAccountIntro": "חבר מפתח פרוקסי (OpenRouter, LiteLLM) המשותף לכל סביבות העבודה בחשבון זה. פרוקסי הוא מתווך החזית בפני ספקים רבים מאחורי מפתח יחיד. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש.",
4192
- "proxyIntro": "חבר מפתח פרוקסי (OpenRouter, LiteLLM). פרוקסי הוא מתווך המגיע למודלים של ספקים רבים דרך שער יחיד, במקום אל ספק יחיד ישירות. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
4191
+ "proxyAccountIntro": "חבר מפתח פרוקסי (OpenRouter, Bifrost, LiteLLM) המשותף לכל סביבות העבודה בחשבון זה. פרוקסי הוא מתווך החזית בפני ספקים רבים מאחורי מפתח יחיד. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש.",
4192
+ "proxyIntro": "חבר מפתח פרוקסי (OpenRouter, Bifrost, LiteLLM). פרוקסי הוא מתווך המגיע למודלים של ספקים רבים דרך שער יחיד, במקום אל ספק יחיד ישירות. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
4193
4193
  "scopeField": "היקף",
4194
4194
  "scopeWorkspace": "סביבת עבודה זו",
4195
4195
  "scopeUser": "המפתחות שלי (רק אני)",
@@ -4247,6 +4247,11 @@
4247
4247
  "step1": "פתח את openrouter.ai → Keys וצור מפתח API.",
4248
4248
  "step2": "העתק את המפתח (מתחיל ב-sk-or-…); הוא מגיע ל-300+ מודלים דרך שער יחיד."
4249
4249
  },
4250
+ "bifrost": {
4251
+ "label": "Bifrost (שער באירוח עצמי)",
4252
+ "step1": "צור מפתח וירטואלי בהגדרות הבקרה של שער Bifrost שלך (או השתמש במפתח ה-API שלו).",
4253
+ "step2": "כתובת הבסיס של השער נקבעת על ידי מפעיל הפריסה שלך (BIFROST_BASE_URL), ולא כאן."
4254
+ },
4250
4255
  "litellm": {
4251
4256
  "label": "LiteLLM (שער בארחה עצמית)",
4252
4257
  "step1": "צור מפתח וירטואלי בשער LiteLLM שלך (או השתמש במפתח המאסטר שלו).",
@@ -3426,8 +3426,8 @@
3426
3426
  "proxyHeading": "Chiavi API proxy / gateway",
3427
3427
  "directAccountIntro": "Collega una chiave API di un vendor condivisa da ogni workspace di questo account. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Le chiavi dell'account sono gestite dall'amministratore.",
3428
3428
  "directIntro": "Collega una chiave API di un vendor così i modelli vengono eseguiti direttamente su quel provider. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
3429
- "proxyAccountIntro": "Collega una chiave proxy (OpenRouter, LiteLLM) condivisa da ogni workspace di questo account. Un proxy è un intermediario che fa da facciata a molti vendor dietro un'unica chiave. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo.",
3430
- "proxyIntro": "Collega una chiave proxy (OpenRouter, LiteLLM). Un proxy è un intermediario che raggiunge i modelli di molti vendor attraverso un unico gateway, anziché un singolo vendor direttamente. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
3429
+ "proxyAccountIntro": "Collega una chiave proxy (OpenRouter, Bifrost, LiteLLM) condivisa da ogni workspace di questo account. Un proxy è un intermediario che fa da facciata a molti vendor dietro un'unica chiave. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo.",
3430
+ "proxyIntro": "Collega una chiave proxy (OpenRouter, Bifrost, LiteLLM). Un proxy è un intermediario che raggiunge i modelli di molti vendor attraverso un unico gateway, anziché un singolo vendor direttamente. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
3431
3431
  "scopeField": "Ambito",
3432
3432
  "scopeWorkspace": "Questo workspace",
3433
3433
  "scopeUser": "Le mie chiavi (solo io)",
@@ -3485,6 +3485,11 @@
3485
3485
  "step1": "Apri openrouter.ai → Keys e crea una chiave API.",
3486
3486
  "step2": "Copia la chiave (inizia con sk-or-…); raggiunge oltre 300 modelli attraverso un unico gateway."
3487
3487
  },
3488
+ "bifrost": {
3489
+ "label": "Bifrost (gateway self-hosted)",
3490
+ "step1": "Genera una chiave virtuale nelle impostazioni di governance del tuo gateway Bifrost (oppure usa la sua chiave API).",
3491
+ "step2": "L'URL di base del gateway viene impostato dall'operatore del tuo deployment (BIFROST_BASE_URL), non qui."
3492
+ },
3488
3493
  "litellm": {
3489
3494
  "label": "LiteLLM (gateway self-hosted)",
3490
3495
  "step1": "Genera una chiave virtuale sul tuo gateway LiteLLM (oppure usa la sua chiave master).",
@@ -4188,8 +4188,8 @@
4188
4188
  "proxyHeading": "プロキシ / ゲートウェイの API キー",
4189
4189
  "directAccountIntro": "このアカウントの全ワークスペースで共有するベンダー API キーを接続します。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。アカウントキーは管理者が管理します。",
4190
4190
  "directIntro": "ベンダー API キーを接続すると、モデルがそのプロバイダー上で直接実行されます。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
4191
- "proxyAccountIntro": "このアカウントの全ワークスペースで共有するプロキシキー (OpenRouter、LiteLLM) を接続します。プロキシは、単一のキーで多数のベンダーをまとめる仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。",
4192
- "proxyIntro": "プロキシキー (OpenRouter、LiteLLM) を接続します。プロキシは、各ベンダーに直接ではなく、単一のゲートウェイを通じて多数のベンダーのモデルに到達する仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
4191
+ "proxyAccountIntro": "このアカウントの全ワークスペースで共有するプロキシキー (OpenRouter、Bifrost、LiteLLM) を接続します。プロキシは、単一のキーで多数のベンダーをまとめる仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。",
4192
+ "proxyIntro": "プロキシキー (OpenRouter、Bifrost、LiteLLM) を接続します。プロキシは、各ベンダーに直接ではなく、単一のゲートウェイを通じて多数のベンダーのモデルに到達する仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
4193
4193
  "scopeField": "スコープ",
4194
4194
  "scopeWorkspace": "このワークスペース",
4195
4195
  "scopeUser": "自分のキー (自分のみ)",
@@ -4247,6 +4247,11 @@
4247
4247
  "step1": "openrouter.ai → Keys を開き、API キーを作成します。",
4248
4248
  "step2": "キー (sk-or-… で始まる) をコピーします。1 つのゲートウェイで 300 以上のモデルに到達します。"
4249
4249
  },
4250
+ "bifrost": {
4251
+ "label": "Bifrost (セルフホストゲートウェイ)",
4252
+ "step1": "Bifrost ゲートウェイのガバナンス設定で仮想キーを生成します (または API キーを使用します)。",
4253
+ "step2": "ゲートウェイのベース URL は、ここではなくデプロイ運用者が設定します (BIFROST_BASE_URL)。"
4254
+ },
4250
4255
  "litellm": {
4251
4256
  "label": "LiteLLM (セルフホストゲートウェイ)",
4252
4257
  "step1": "LiteLLM ゲートウェイで仮想キーを生成します (またはマスターキーを使用します)。",