@cat-factory/app 0.35.0 → 0.37.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 (87) hide show
  1. package/app/components/auth/UserMenu.vue +11 -1
  2. package/app/components/brainstorm/BrainstormWindow.vue +2 -1
  3. package/app/components/clarity/ClarityReviewWindow.vue +2 -1
  4. package/app/components/gates/GateResultView.vue +107 -12
  5. package/app/components/layout/IntegrationBackTitle.vue +12 -7
  6. package/app/components/layout/IntegrationsHub.vue +191 -43
  7. package/app/components/layout/NotificationsInbox.vue +16 -0
  8. package/app/components/layout/PersonalSetupModal.vue +141 -0
  9. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  10. package/app/components/providers/VendorCredentialsModal.vue +7 -2
  11. package/app/components/slack/SlackPanel.vue +1 -0
  12. package/app/composables/api/accounts.ts +36 -51
  13. package/app/composables/api/auth.ts +20 -19
  14. package/app/composables/api/board.ts +60 -40
  15. package/app/composables/api/bootstrap.ts +25 -22
  16. package/app/composables/api/client.ts +102 -0
  17. package/app/composables/api/context.ts +25 -6
  18. package/app/composables/api/documents.ts +36 -34
  19. package/app/composables/api/execution.ts +65 -48
  20. package/app/composables/api/followUps.ts +26 -26
  21. package/app/composables/api/fragments.ts +47 -34
  22. package/app/composables/api/github.ts +65 -45
  23. package/app/composables/api/humanReview.ts +19 -0
  24. package/app/composables/api/humanTest.ts +15 -11
  25. package/app/composables/api/kaizen.ts +8 -6
  26. package/app/composables/api/localSettings.ts +5 -4
  27. package/app/composables/api/models.ts +58 -51
  28. package/app/composables/api/notifications.ts +13 -7
  29. package/app/composables/api/presets.ts +34 -28
  30. package/app/composables/api/providerConnections.ts +68 -26
  31. package/app/composables/api/recurring.ts +40 -30
  32. package/app/composables/api/releaseHealth.ts +28 -26
  33. package/app/composables/api/reviews.ts +136 -114
  34. package/app/composables/api/sandbox.ts +52 -34
  35. package/app/composables/api/slack.ts +22 -25
  36. package/app/composables/api/spec.ts +3 -3
  37. package/app/composables/api/tasks.ts +42 -41
  38. package/app/composables/api/userSecrets.ts +12 -17
  39. package/app/composables/api/workspaces.ts +21 -15
  40. package/app/composables/useApi.ts +11 -1
  41. package/app/composables/useIntegrationBack.ts +9 -3
  42. package/app/pages/index.vue +2 -0
  43. package/app/stores/auth.ts +2 -1
  44. package/app/stores/board.ts +2 -1
  45. package/app/stores/brainstorm.ts +2 -2
  46. package/app/stores/clarity.ts +6 -2
  47. package/app/stores/execution.ts +3 -2
  48. package/app/stores/github.ts +1 -2
  49. package/app/stores/humanReview.ts +41 -0
  50. package/app/stores/mergePresets.ts +2 -6
  51. package/app/stores/pipelines.ts +1 -1
  52. package/app/stores/recurringPipelines.ts +2 -7
  53. package/app/stores/sandbox.ts +1 -2
  54. package/app/stores/ui.ts +62 -19
  55. package/app/types/accountSettings.ts +11 -36
  56. package/app/types/accounts.ts +16 -71
  57. package/app/types/bootstrap.ts +13 -75
  58. package/app/types/brainstorm.ts +13 -38
  59. package/app/types/clarity.ts +12 -43
  60. package/app/types/consensus.ts +16 -89
  61. package/app/types/documents.ts +19 -94
  62. package/app/types/domain.ts +54 -582
  63. package/app/types/execution.ts +48 -499
  64. package/app/types/fragments.ts +15 -83
  65. package/app/types/github.ts +25 -161
  66. package/app/types/incidentEnrichment.ts +10 -25
  67. package/app/types/localModels.ts +11 -61
  68. package/app/types/localSettings.ts +9 -26
  69. package/app/types/merge.ts +10 -68
  70. package/app/types/model-presets.ts +7 -28
  71. package/app/types/models.ts +16 -164
  72. package/app/types/notifications.ts +18 -76
  73. package/app/types/openrouter.ts +8 -34
  74. package/app/types/providerConnections.ts +21 -41
  75. package/app/types/provisioningLogs.ts +9 -29
  76. package/app/types/recurring.ts +10 -63
  77. package/app/types/releaseHealth.ts +15 -39
  78. package/app/types/requirements.ts +14 -84
  79. package/app/types/sandbox.ts +45 -161
  80. package/app/types/services.ts +3 -22
  81. package/app/types/slack.ts +10 -47
  82. package/app/types/spec.ts +15 -68
  83. package/app/types/tasks.ts +15 -111
  84. package/app/types/tracker.ts +9 -24
  85. package/app/types/userSecrets.ts +12 -47
  86. package/app/utils/catalog.ts +12 -0
  87. package/package.json +9 -2
@@ -1,10 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  import type { DropdownMenuItem } from '@nuxt/ui'
3
3
 
4
- // Signed-in identity + sign-out, shown in the sidebar when auth is enabled.
4
+ // Signed-in identity + per-user actions, shown in the sidebar when auth is enabled.
5
5
  const auth = useAuthStore()
6
+ const ui = useUiStore()
6
7
 
7
8
  const items = computed<DropdownMenuItem[][]>(() => [
9
+ [
10
+ {
11
+ // The user-scoped "My setup" hub (personal GitHub token, local runners, personal
12
+ // subscriptions) — kept out of the workspace Integrations hub, reachable here.
13
+ label: 'My setup',
14
+ icon: 'i-lucide-user-cog',
15
+ onSelect: () => ui.openPersonalSetup(),
16
+ },
17
+ ],
8
18
  [
9
19
  {
10
20
  label: 'Sign out',
@@ -10,6 +10,7 @@
10
10
  import { parseOutputOutline } from '~/utils/agentOutput'
11
11
  import type {
12
12
  BrainstormItem,
13
+ BrainstormItemStatus,
13
14
  BrainstormSession,
14
15
  BrainstormStage,
15
16
  ReviewItemCategory,
@@ -136,7 +137,7 @@ async function submitReply(item: BrainstormItem) {
136
137
  }
137
138
  }
138
139
 
139
- async function setStatus(item: BrainstormItem, itemStatus: ReviewItemStatus) {
140
+ async function setStatus(item: BrainstormItem, itemStatus: BrainstormItemStatus) {
140
141
  if (!session.value) return
141
142
  try {
142
143
  await brainstorm.setItemStatus(session.value, item.id, itemStatus)
@@ -10,6 +10,7 @@
10
10
  // consumes.
11
11
  import { parseOutputOutline } from '~/utils/agentOutput'
12
12
  import type {
13
+ ClarityItemStatus,
13
14
  ClarityReview,
14
15
  ClarityReviewItem,
15
16
  ReviewItemCategory,
@@ -131,7 +132,7 @@ async function submitReply(item: ClarityReviewItem) {
131
132
  }
132
133
  }
133
134
 
134
- async function setStatus(item: ClarityReviewItem, itemStatus: ReviewItemStatus) {
135
+ async function setStatus(item: ClarityReviewItem, itemStatus: ClarityItemStatus) {
135
136
  if (!review.value) return
136
137
  try {
137
138
  await clarity.setItemStatus(review.value, item.id, itemStatus)
@@ -5,7 +5,7 @@
5
5
  // persists on `step.gate`: the precheck verdict, the helper attempt budget, the gated
6
6
  // commit, and — for CI — the failing checks behind the failure. One window serves both
7
7
  // gates; it branches on the step's `agentKind` for the copy and the failure detail.
8
- import { computed } from 'vue'
8
+ import { computed, ref } from 'vue'
9
9
  import { agentKindMeta } from '~/utils/catalog'
10
10
  import type { GateStepState } from '~/types/execution'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
@@ -30,10 +30,37 @@ const step = computed(() => {
30
30
  const gate = computed<GateStepState | null>(() => step.value?.gate ?? null)
31
31
 
32
32
  const isCi = computed(() => step.value?.agentKind === 'ci')
33
+ const isHumanReview = computed(() => step.value?.agentKind === 'human-review')
33
34
  const meta = computed(() => agentKindMeta(step.value?.agentKind ?? 'ci'))
34
- const helperKind = computed(() => (isCi.value ? 'ci-fixer' : 'conflict-resolver'))
35
+ const helperKind = computed(() =>
36
+ isHumanReview.value ? 'fixer' : isCi.value ? 'ci-fixer' : 'conflict-resolver',
37
+ )
35
38
  const helperMeta = computed(() => agentKindMeta(helperKind.value))
36
39
 
40
+ const subtitle = computed(() =>
41
+ isHumanReview.value
42
+ ? 'Waits for a human code review on the PR, looping the fixer on comments'
43
+ : isCi.value
44
+ ? 'Gates the PR on green CI, looping the CI fixer on failure'
45
+ : 'Gates the PR on a clean merge, looping the resolver on conflicts',
46
+ )
47
+
48
+ // Human-review: approval progress + the freeform "request a fix" control.
49
+ const humanReview = useHumanReviewStore()
50
+ const fixInstructions = ref('')
51
+ const fixBusy = computed(() => (blockId.value ? humanReview.isBusy(blockId.value) : false))
52
+ async function submitFix() {
53
+ const id = blockId.value
54
+ const text = fixInstructions.value.trim()
55
+ if (!id || !text) return
56
+ await humanReview.requestFix(id, text)
57
+ fixInstructions.value = ''
58
+ }
59
+
60
+ // The displayed "required approvals" is derived from the cached branch-protection count via
61
+ // the gate's effective floor (`max(1, …)`, see review.logic.ts) rather than persisted twice.
62
+ const requiredApprovals = computed(() => Math.max(1, gate.value?.requiredApprovingReviewCount ?? 1))
63
+
37
64
  const failingChecks = computed(() => gate.value?.failingChecks ?? [])
38
65
  const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(0, 7) : null))
39
66
 
@@ -130,13 +157,7 @@ const conflictVerdict = computed(() => {
130
157
  <h2 class="truncate text-sm font-semibold text-slate-100">
131
158
  {{ meta.label }}{{ block ? ` — ${block.title}` : '' }}
132
159
  </h2>
133
- <p class="truncate text-[11px] text-slate-400">
134
- {{
135
- isCi
136
- ? 'Gates the PR on green CI, looping the CI fixer on failure'
137
- : 'Gates the PR on a clean merge, looping the resolver on conflicts'
138
- }}
139
- </p>
160
+ <p class="truncate text-[11px] text-slate-400">{{ subtitle }}</p>
140
161
  </div>
141
162
  <UBadge :color="STATUS_META[status].badge" variant="subtle" size="sm">
142
163
  {{ STATUS_META[status].label }}
@@ -186,6 +207,73 @@ const conflictVerdict = computed(() => {
186
207
  </p>
187
208
  </div>
188
209
 
210
+ <!-- Human review: approval progress, the feedback being fixed, freeform fix box -->
211
+ <template v-else-if="isHumanReview">
212
+ <div
213
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
214
+ >
215
+ <UIcon name="i-lucide-users" class="h-4 w-4 shrink-0 text-violet-300" />
216
+ <span class="text-[13px] text-slate-200">
217
+ {{ gate.lastApprovals ?? 0 }} / {{ requiredApprovals }} approval{{
218
+ requiredApprovals === 1 ? '' : 's'
219
+ }}
220
+ <template v-if="status === 'fixing'"> · fixer addressing comments…</template>
221
+ <template v-else-if="status === 'failing'">
222
+ · review comments to address</template
223
+ >
224
+ <template v-else> · awaiting review</template>
225
+ </span>
226
+ </div>
227
+ <p
228
+ v-if="gate.lastFailureSummary"
229
+ class="mt-2 whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] leading-relaxed text-slate-300"
230
+ >
231
+ {{ gate.lastFailureSummary }}
232
+ </p>
233
+ <a
234
+ v-if="prUrl"
235
+ :href="prUrl"
236
+ target="_blank"
237
+ rel="noopener"
238
+ class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
239
+ >
240
+ Review pull request on GitHub
241
+ <UIcon name="i-lucide-external-link" class="h-3 w-3" />
242
+ </a>
243
+
244
+ <!-- Freeform fix request: dispatch the fixer now with these instructions. -->
245
+ <section v-if="status !== 'gave-up'" class="mt-4">
246
+ <h3
247
+ class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
248
+ >
249
+ Request a fix
250
+ </h3>
251
+ <p class="mb-2 text-[11px] leading-relaxed text-slate-500">
252
+ Describe a change for the fixer to make on the PR branch now (in addition to any
253
+ review comments, which it addresses automatically).
254
+ </p>
255
+ <textarea
256
+ v-model="fixInstructions"
257
+ rows="3"
258
+ :disabled="fixBusy"
259
+ placeholder="e.g. rename the helper and add a unit test for the empty-input case"
260
+ class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none"
261
+ />
262
+ <div class="mt-2 flex justify-end">
263
+ <UButton
264
+ size="sm"
265
+ color="primary"
266
+ icon="i-lucide-wrench"
267
+ :loading="fixBusy"
268
+ :disabled="fixBusy || fixInstructions.trim().length === 0"
269
+ @click="submitFix"
270
+ >
271
+ Request fix
272
+ </UButton>
273
+ </div>
274
+ </section>
275
+ </template>
276
+
189
277
  <!-- CI: failing checks -->
190
278
  <template v-else-if="isCi">
191
279
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
@@ -321,9 +409,16 @@ const conflictVerdict = computed(() => {
321
409
  {{ helperMeta.label }}
322
410
  </h4>
323
411
  <p class="text-[12px] text-slate-300">
324
- {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
325
- gate.maxAttempts === 1 ? '' : 's'
326
- }}
412
+ <!-- The human-review gate's budget is effectively unbounded (it waits for a human
413
+ indefinitely), so render a plain round count rather than "0/9007199254740991". -->
414
+ <template v-if="isHumanReview">
415
+ {{ gate.attempts }} fix round{{ gate.attempts === 1 ? '' : 's' }}
416
+ </template>
417
+ <template v-else>
418
+ {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
419
+ gate.maxAttempts === 1 ? '' : 's'
420
+ }}
421
+ </template>
327
422
  <template v-if="gate.phase === 'working'"> · running…</template>
328
423
  <template v-else-if="gate.attempts === 0"> · not needed yet</template>
329
424
  </p>
@@ -1,25 +1,30 @@
1
1
  <script setup lang="ts">
2
2
  // Title content for an integration sub-panel's modal header. Renders the panel
3
- // title with a leading "back" control that returns to the Integrations hub, shown
4
- // only when the panel was actually reached from there (`ui.cameFromIntegrations`).
5
- // Panels opened from the command bar, sidebar, a banner or an inspector link don't
6
- // grow a dead Back. Dropped into a UModal's #title slot, so it inherits the modal's
7
- // title styling; it emits `back` and the host panel closes itself + reopens the hub.
3
+ // title with a leading "back" control that returns to the hub the panel was reached
4
+ // from the workspace Integrations hub (`ui.cameFromIntegrations`) or the user-scoped
5
+ // "My setup" hub (`ui.cameFromPersonal`) shown only when there is one. Panels opened
6
+ // from the command bar, sidebar, a banner or an inspector link don't grow a dead Back.
7
+ // Dropped into a UModal's #title slot, so it inherits the modal's title styling; it
8
+ // emits `back` and the host panel closes itself + reopens the right hub.
8
9
  defineProps<{ title?: string }>()
9
10
  const emit = defineEmits<{ back: [] }>()
10
11
  const ui = useUiStore()
12
+ const cameFromHub = computed(() => ui.cameFromIntegrations || ui.cameFromPersonal)
13
+ const backLabel = computed(() =>
14
+ ui.cameFromPersonal ? 'Back to My setup' : 'Back to Integrations',
15
+ )
11
16
  </script>
12
17
 
13
18
  <template>
14
19
  <span class="flex items-center gap-1.5">
15
20
  <UButton
16
- v-if="ui.cameFromIntegrations"
21
+ v-if="cameFromHub"
17
22
  icon="i-lucide-arrow-left"
18
23
  color="neutral"
19
24
  variant="ghost"
20
25
  size="xs"
21
26
  class="-ml-1.5 shrink-0"
22
- aria-label="Back to Integrations"
27
+ :aria-label="backLabel"
23
28
  @click.stop="emit('back')"
24
29
  />
25
30
  <span class="min-w-0 truncate">{{ title }}</span>
@@ -1,12 +1,16 @@
1
1
  <script setup lang="ts">
2
- // The Integrations hub: a single modal that lists every external system the
3
- // workspace can enable or link in replacing the per-integration buttons that
4
- // used to clutter the left navbar. Each row reuses the existing per-integration
5
- // panel handlers on the `ui` store (so the integrations themselves are unchanged);
6
- // opening one closes the hub and reveals that integration's own panel/modal.
2
+ // The Integrations hub: a single modal that lists every external system the WORKSPACE can
3
+ // enable or link in. Each row reuses the existing per-integration panel handlers on the `ui`
4
+ // store (so the integrations themselves are unchanged); opening one closes the hub and
5
+ // reveals that integration's own panel/modal.
7
6
  //
8
- // Sections gate on the same `available` probes the navbar used, so a system that
9
- // the backend has turned off simply doesn't appear here.
7
+ // Sections gate on the same `available` probes the navbar used, so a system that the backend
8
+ // has turned off simply doesn't appear here.
9
+ //
10
+ // Scope split: per-USER connections (a personal GitHub token, own-machine runners, personal
11
+ // subscriptions) now live in the "My setup" hub (UserMenu → My setup), NOT here — keeping
12
+ // this hub purely workspace-scoped. When auth is disabled there is no UserMenu to host them,
13
+ // so a "Personal (only you)" group falls back into this hub so they stay reachable.
10
14
  const ui = useUiStore()
11
15
  const auth = useAuthStore()
12
16
  const github = useGitHubStore()
@@ -20,6 +24,11 @@ const userSecrets = useUserSecretsStore()
20
24
  const apiKeys = useApiKeysStore()
21
25
  const workspace = useWorkspaceStore()
22
26
 
27
+ // True when the per-user "My setup" hub is reachable (UserMenu renders only when signed in).
28
+ // When false (auth disabled / local mode) we fold the personal rows back into this hub so
29
+ // nothing becomes unreachable.
30
+ const personalHubReachable = computed(() => !!auth.user)
31
+
23
32
  // The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
24
33
  const trackerLabel = computed(() => {
25
34
  if (tracker.settings.tracker === 'github') return 'GitHub Issues'
@@ -27,12 +36,17 @@ const trackerLabel = computed(() => {
27
36
  return undefined
28
37
  })
29
38
 
39
+ // Free-text filter over the rows (label + description), so a workspace with many enabled
40
+ // systems stays scannable. Reset when the hub re-opens.
41
+ const query = ref('')
42
+
30
43
  // The observability connection status drives the hub's connected badge. Load it
31
44
  // lazily when the hub opens (the secret-less connection view is cheap).
32
45
  watch(
33
46
  () => ui.integrationsOpen,
34
47
  (isOpen) => {
35
48
  if (isOpen) {
49
+ query.value = ''
36
50
  void releaseHealth.ensureLoaded().catch(() => {})
37
51
  void providerConnections.ensureLoaded().catch(() => {})
38
52
  void userSecrets.load().catch(() => {})
@@ -47,8 +61,10 @@ const open = computed({
47
61
  set: (v: boolean) => (v ? ui.openIntegrations() : ui.closeIntegrations()),
48
62
  })
49
63
 
50
- // One integration row. `status` is the connected-state line shown under the label
51
- // (an account/team name, "Connected", or a hint); `connected` drives the badge.
64
+ // One integration row. `connected` drives the green badge (`status` is its line — an
65
+ // account/team name or "Connected"); `attention` drives an amber badge (e.g. a source that
66
+ // is available but turned off) with `attentionLabel`. `recommended` tags an essential row
67
+ // with a "Recommended" chip while the workspace has nothing connected yet.
52
68
  interface IntegrationItem {
53
69
  key: string
54
70
  icon: string
@@ -56,12 +72,27 @@ interface IntegrationItem {
56
72
  description: string
57
73
  status?: string
58
74
  connected?: boolean
75
+ attention?: boolean
76
+ attentionLabel?: string
77
+ recommended?: boolean
78
+ onClick: () => void
79
+ }
80
+
81
+ // A group may carry a small de-emphasised footer LINK (workspace config that isn't itself an
82
+ // integration, e.g. the issue-tracker settings) rendered under its rows rather than as a
83
+ // full row competing with the connections.
84
+ interface IntegrationFooterLink {
85
+ key: string
86
+ icon: string
87
+ label: string
88
+ status?: string
59
89
  onClick: () => void
60
90
  }
61
91
 
62
92
  interface IntegrationGroup {
63
93
  title: string
64
94
  items: IntegrationItem[]
95
+ footerLink?: IntegrationFooterLink
65
96
  }
66
97
 
67
98
  // Run an integration's open handler, then dismiss the hub so its panel takes over.
@@ -87,22 +118,16 @@ const groups = computed<IntegrationGroup[]>(() => {
87
118
  description: 'One gateway to 300+ models — add your key and enable models in one place.',
88
119
  status: openRouterKeyConnected ? 'Key connected' : undefined,
89
120
  connected: openRouterKeyConnected,
121
+ recommended: true,
90
122
  onClick: () => go(ui.openOpenRouter),
91
123
  },
92
124
  {
93
125
  key: 'vendors',
94
126
  icon: 'i-lucide-key-round',
95
127
  label: 'Vendors & keys',
96
- description: 'LLM vendor subscriptions and provider API keys.',
128
+ description: 'Workspace LLM subscriptions and provider API keys.',
97
129
  onClick: () => go(ui.openVendorCredentials),
98
130
  },
99
- {
100
- key: 'local-runners',
101
- icon: 'i-lucide-server',
102
- label: 'My local runners',
103
- description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
104
- onClick: () => go(ui.openLocalModels),
105
- },
106
131
  ],
107
132
  })
108
133
 
@@ -116,23 +141,10 @@ const groups = computed<IntegrationGroup[]>(() => {
116
141
  description: 'Connect the workspace’s GitHub App, browse repos, PRs and issues.',
117
142
  status: github.connected ? github.connection?.accountLogin : undefined,
118
143
  connected: github.connected,
144
+ recommended: true,
119
145
  onClick: () => go(ui.openGitHub),
120
146
  })
121
147
  }
122
- // Per-user GitHub PAT — works on every runtime (used for runs you initiate). Always
123
- // offered; the badge reflects whether the signed-in user has stored one.
124
- {
125
- const pat = userSecrets.statusFor('github_pat')
126
- code.push({
127
- key: 'github-pat',
128
- icon: 'i-lucide-key-round',
129
- label: 'My GitHub token',
130
- description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
131
- status: pat ? 'Connected' : undefined,
132
- connected: !!pat,
133
- onClick: () => go(ui.openUserSecrets),
134
- })
135
- }
136
148
  if (code.length) out.push({ title: 'Source control', items: code })
137
149
 
138
150
  // --- Communication ---------------------------------------------------------
@@ -180,10 +192,11 @@ const groups = computed<IntegrationGroup[]>(() => {
180
192
  icon: src.icon,
181
193
  label: src.label,
182
194
  description: `Link ${src.label} to import and reference tracker issues.`,
183
- // Available + enabled ⇒ offered (green); available + off ⇒ "Disabled";
195
+ // Available + enabled ⇒ offered (green); available + off ⇒ "Disabled" (amber);
184
196
  // not available ⇒ no badge (Jira needs connecting; GitHub needs its App).
185
- status: src.available ? (src.enabled ? undefined : 'Disabled') : undefined,
186
197
  connected: src.available && src.enabled,
198
+ attention: src.available && !src.enabled,
199
+ attentionLabel: 'Disabled',
187
200
  onClick: () => go(() => ui.openTaskConnect(src.source)),
188
201
  }))
189
202
  if (tasks.anyOffered) {
@@ -195,16 +208,19 @@ const groups = computed<IntegrationGroup[]>(() => {
195
208
  onClick: () => go(() => ui.openTaskImport(null)),
196
209
  })
197
210
  }
198
- trackers.push({
199
- key: 'task:tracker',
200
- icon: 'i-lucide-list-checks',
201
- label: 'Issue tracker settings',
202
- description: 'Choose the filing tracker, enable linking sources, and configure writeback.',
203
- status: trackerLabel.value,
204
- connected: tracker.settings.tracker !== null,
205
- onClick: () => go(() => ui.openWorkspaceSettings('tracker')),
211
+ // Choosing the filing tracker / writeback is workspace CONFIG, not an integration, so it
212
+ // sits as a quiet footer link under the sources rather than a competing row.
213
+ out.push({
214
+ title: 'Task trackers',
215
+ items: trackers,
216
+ footerLink: {
217
+ key: 'task:tracker',
218
+ icon: 'i-lucide-list-checks',
219
+ label: 'Issue tracker settings',
220
+ status: trackerLabel.value,
221
+ onClick: () => go(() => ui.openWorkspaceSettings('tracker')),
222
+ },
206
223
  })
207
- out.push({ title: 'Task trackers', items: trackers })
208
224
  }
209
225
 
210
226
  // --- Observability ---------------------------------------------------------
@@ -271,8 +287,73 @@ const groups = computed<IntegrationGroup[]>(() => {
271
287
  }
272
288
  if (infra.length) out.push({ title: 'Infrastructure', items: infra })
273
289
 
290
+ // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
291
+ // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
292
+ // here so they stay reachable. (The badge reflects the signed-in user's stored secret.)
293
+ if (!personalHubReachable.value) {
294
+ const pat = !!userSecrets.statusFor('github_pat')
295
+ out.push({
296
+ title: 'Personal (only you)',
297
+ items: [
298
+ {
299
+ key: 'github-pat',
300
+ icon: 'i-lucide-key-round',
301
+ label: 'My GitHub token',
302
+ description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
303
+ status: pat ? 'Connected' : undefined,
304
+ connected: pat,
305
+ onClick: () => go(ui.openUserSecrets),
306
+ },
307
+ {
308
+ key: 'local-runners',
309
+ icon: 'i-lucide-server',
310
+ label: 'My local runners',
311
+ description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
312
+ onClick: () => go(ui.openLocalModels),
313
+ },
314
+ ],
315
+ })
316
+ }
317
+
274
318
  return out
275
319
  })
320
+
321
+ // Sort connected rows first, then amber "attention", then idle — a stable rank so each
322
+ // group reads "what's live" top-down without reshuffling unrelated rows.
323
+ function stateRank(item: IntegrationItem): number {
324
+ if (item.connected) return 0
325
+ if (item.attention) return 1
326
+ return 2
327
+ }
328
+
329
+ const allItems = computed(() => groups.value.flatMap((g) => g.items))
330
+ const anyConnected = computed(() => allItems.value.some((i) => i.connected))
331
+ // Essential rows still unconnected — surfaced as the empty-workspace get-started shortcuts.
332
+ const recommendedActions = computed(() =>
333
+ allItems.value.filter((i) => i.recommended && !i.connected),
334
+ )
335
+
336
+ function matches(text: string, q: string): boolean {
337
+ return text.toLowerCase().includes(q)
338
+ }
339
+
340
+ // Groups after the search filter + connected-first sort. A footer link is kept only when it
341
+ // also matches the query (or the query is empty); a group with no surviving rows/link drops.
342
+ const filteredGroups = computed<IntegrationGroup[]>(() => {
343
+ const q = query.value.trim().toLowerCase()
344
+ return groups.value
345
+ .map((g) => {
346
+ const items = (
347
+ q ? g.items.filter((i) => matches(i.label, q) || matches(i.description, q)) : g.items
348
+ )
349
+ .slice()
350
+ .sort((a, b) => stateRank(a) - stateRank(b))
351
+ const footerLink =
352
+ g.footerLink && (!q || matches(g.footerLink.label, q)) ? g.footerLink : undefined
353
+ return { ...g, items, footerLink }
354
+ })
355
+ .filter((g) => g.items.length || g.footerLink)
356
+ })
276
357
  </script>
277
358
 
278
359
  <template>
@@ -283,7 +364,47 @@ const groups = computed<IntegrationGroup[]>(() => {
283
364
  Connect and manage the external systems this workspace can link in.
284
365
  </p>
285
366
 
286
- <section v-for="group in groups" :key="group.title">
367
+ <!-- Get-started cue: an empty workspace gets the two essentials up front so the first
368
+ run isn't blocked on hunting for them. Hidden once anything is connected. -->
369
+ <div
370
+ v-if="!anyConnected && recommendedActions.length"
371
+ class="rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
372
+ >
373
+ <div class="mb-2 flex items-center gap-2 text-sm font-medium text-primary-200">
374
+ <UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0" />
375
+ <span>Get started</span>
376
+ </div>
377
+ <p class="mb-3 text-xs text-slate-300">
378
+ Connect a code source and a model provider to run your first pipeline.
379
+ </p>
380
+ <div class="flex flex-wrap gap-2">
381
+ <UButton
382
+ v-for="item in recommendedActions"
383
+ :key="`rec:${item.key}`"
384
+ size="xs"
385
+ color="primary"
386
+ variant="soft"
387
+ :icon="item.icon"
388
+ @click="item.onClick()"
389
+ >
390
+ {{ item.label }}
391
+ </UButton>
392
+ </div>
393
+ </div>
394
+
395
+ <UInput
396
+ v-model="query"
397
+ icon="i-lucide-search"
398
+ size="sm"
399
+ placeholder="Search integrations…"
400
+ class="w-full"
401
+ />
402
+
403
+ <p v-if="!filteredGroups.length" class="px-1 py-6 text-center text-sm text-slate-500">
404
+ No integrations match “{{ query }}”.
405
+ </p>
406
+
407
+ <section v-for="group in filteredGroups" :key="group.title">
287
408
  <h3 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
288
409
  {{ group.title }}
289
410
  </h3>
@@ -302,12 +423,39 @@ const groups = computed<IntegrationGroup[]>(() => {
302
423
  <UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
303
424
  {{ item.status || 'Connected' }}
304
425
  </UBadge>
426
+ <UBadge v-else-if="item.attention" color="warning" variant="subtle" size="sm">
427
+ {{ item.attentionLabel || 'Needs attention' }}
428
+ </UBadge>
429
+ <span v-else class="text-[11px] text-slate-500">Not connected</span>
430
+ <UBadge
431
+ v-if="!anyConnected && item.recommended && !item.connected"
432
+ color="primary"
433
+ variant="subtle"
434
+ size="sm"
435
+ >
436
+ Recommended
437
+ </UBadge>
305
438
  </div>
306
439
  <p class="truncate text-xs text-slate-400">{{ item.description }}</p>
307
440
  </div>
308
441
  <UIcon name="i-lucide-chevron-right" class="h-4 w-4 shrink-0 text-slate-500" />
309
442
  </button>
310
443
  </div>
444
+
445
+ <!-- De-emphasised workspace-config link (e.g. issue tracker settings). -->
446
+ <button
447
+ v-if="group.footerLink"
448
+ type="button"
449
+ class="mt-1.5 flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-xs text-slate-400 transition hover:bg-slate-900/60 hover:text-slate-200"
450
+ @click="group.footerLink.onClick()"
451
+ >
452
+ <UIcon :name="group.footerLink.icon" class="h-3.5 w-3.5 shrink-0" />
453
+ <span class="flex-1 truncate">{{ group.footerLink.label }}</span>
454
+ <span v-if="group.footerLink.status" class="shrink-0 text-slate-500">{{
455
+ group.footerLink.status
456
+ }}</span>
457
+ <UIcon name="i-lucide-chevron-right" class="h-3.5 w-3.5 shrink-0 text-slate-600" />
458
+ </button>
311
459
  </section>
312
460
  </div>
313
461
  </template>
@@ -36,6 +36,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
36
36
  // Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
37
37
  // marks it read (the gate is resolved in that window — confirm / request a fix — not here).
38
38
  human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
39
+ // Clicking the title opens the task's gate window (where the human can request a freeform
40
+ // fix); "act" just marks it read (approval happens on GitHub, not here).
41
+ human_review: { icon: 'i-lucide-users', color: 'primary', action: 'Mark read' },
39
42
  // Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
40
43
  // just marks it read (items are decided in that window — file / send back / answer — not here).
41
44
  followup_pending: { icon: 'i-lucide-compass', color: 'warning', action: 'Mark read' },
@@ -84,10 +87,23 @@ function reveal(n: Notification) {
84
87
  else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
85
88
  else if (n.type === 'decision_required') revealDecision(n)
86
89
  else if (n.type === 'human_test_ready') revealHumanTest(n)
90
+ else if (n.type === 'human_review') revealHumanReview(n)
87
91
  else if (n.type === 'followup_pending') revealFollowUps(n)
88
92
  else ui.select(n.blockId)
89
93
  }
90
94
 
95
+ /**
96
+ * Open the gate window for a parked `human-review` gate: find the run's human-review step and
97
+ * open it through the universal step dispatch (its archetype declares the `gate` result view,
98
+ * where the human can request a freeform fix). Falls back to focusing the block.
99
+ */
100
+ function revealHumanReview(n: Notification) {
101
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
102
+ const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
103
+ if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
104
+ else if (n.blockId) ui.select(n.blockId)
105
+ }
106
+
91
107
  /**
92
108
  * Open the Follow-up companion window for a run whose Coder parked on undecided items.
93
109
  * Falls back to focusing the block when the run isn't loaded.