@cat-factory/app 0.11.0 → 0.12.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.
@@ -187,7 +187,7 @@ const pendingContext = ref<PendingContext[]>([])
187
187
  // always shown (ungated): when the relevant integration isn't connected the Attach
188
188
  // button is disabled with a tooltip rather than the section being hidden.
189
189
  const docsConnected = computed(() => documents.available && documents.anyConnected)
190
- const issuesConnected = computed(() => tasks.available && tasks.anyConnected)
190
+ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
191
191
  const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
192
192
  const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
193
193
 
@@ -111,14 +111,14 @@ const commands = computed<Command[]>(() => {
111
111
  for (const src of tasks.sources) {
112
112
  list.push({
113
113
  id: `task-connect-${src.source}`,
114
- label: tasks.isConnected(src.source) ? `Manage ${src.label}` : `Connect ${src.label}`,
114
+ label: src.available ? `Manage ${src.label}` : `Connect ${src.label}`,
115
115
  group: 'Integrations',
116
116
  icon: src.icon,
117
117
  keywords: 'task source tracker issues',
118
118
  run: () => ui.openTaskConnect(src.source),
119
119
  })
120
120
  }
121
- if (tasks.anyConnected) {
121
+ if (tasks.anyOffered) {
122
122
  list.push({
123
123
  id: 'task-import',
124
124
  label: 'Import issues',
@@ -114,11 +114,13 @@ const groups = computed<IntegrationGroup[]>(() => {
114
114
  icon: src.icon,
115
115
  label: src.label,
116
116
  description: `Link ${src.label} to import and reference tracker issues.`,
117
- status: tasks.isConnected(src.source) ? 'Connected' : undefined,
118
- connected: tasks.isConnected(src.source),
117
+ // Available + enabled ⇒ offered (green); available + off ⇒ "Disabled";
118
+ // not available ⇒ no badge (Jira needs connecting; GitHub needs its App).
119
+ status: src.available ? (src.enabled ? undefined : 'Disabled') : undefined,
120
+ connected: src.available && src.enabled,
119
121
  onClick: () => go(() => ui.openTaskConnect(src.source)),
120
122
  }))
121
- if (tasks.anyConnected) {
123
+ if (tasks.anyOffered) {
122
124
  trackers.push({
123
125
  key: 'task:import',
124
126
  icon: 'i-lucide-file-down',
@@ -386,7 +386,7 @@ const showOriginalDescription = ref(false)
386
386
  icon="i-lucide-ticket"
387
387
  @click="ui.openTaskImport()"
388
388
  >
389
- {{ tasks.anyConnected ? 'Import Jira issue' : 'Connect Jira' }}
389
+ {{ tasks.anyOffered ? 'Import issue' : 'Connect a tracker' }}
390
390
  </UButton>
391
391
  <UButton
392
392
  v-if="isContainer && documents.available && documents.anyConnected"
@@ -24,7 +24,7 @@ const ref_ = ref('')
24
24
  const importing = ref(false)
25
25
 
26
26
  const sourceItems = computed(() =>
27
- tasks.connectedSources.map((s) => ({ label: s.label, value: s.source })),
27
+ tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
28
28
  )
29
29
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
30
30
 
@@ -52,7 +52,7 @@ const creatingId = ref<string | null>(null)
52
52
  watch(open, (isOpen) => {
53
53
  if (isOpen) {
54
54
  ref_.value = ''
55
- source.value = ui.taskImport?.source ?? tasks.connectedSources[0]?.source ?? undefined
55
+ source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
56
56
  containerId.value = containerItems.value[0]?.value
57
57
  creatingId.value = null
58
58
  tasks.loadTasks().catch(() => {})
@@ -102,10 +102,10 @@ async function doImport() {
102
102
  <template>
103
103
  <UModal v-model:open="open" title="Import from a task source">
104
104
  <template #body>
105
- <!-- Empty state: no connections -->
106
- <div v-if="!tasks.anyConnected" class="space-y-3 text-center">
105
+ <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
106
+ <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
107
107
  <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
108
- <p class="text-sm text-slate-400">Connect a task source first.</p>
108
+ <p class="text-sm text-slate-400">Connect or enable a task source first.</p>
109
109
  <div class="flex justify-center gap-2">
110
110
  <UButton
111
111
  v-for="s in tasks.sources"
@@ -115,7 +115,7 @@ async function doImport() {
115
115
  :icon="s.icon"
116
116
  @click="ui.openTaskConnect(s.source)"
117
117
  >
118
- Connect {{ s.label }}
118
+ {{ s.available ? `Enable ${s.label}` : `Connect ${s.label}` }}
119
119
  </UButton>
120
120
  </div>
121
121
  </div>
@@ -1,9 +1,16 @@
1
1
  <script setup lang="ts">
2
- // Connect (or disconnect) the workspace to a task source. The form is rendered
3
- // generically from the source's descriptor (credential fields), so the same
4
- // modal serves Jira and any future tracker. Secret credentials are write-only
5
- // the backend never returns them, so on reload we show "Connected" with empty
6
- // fields.
2
+ // Connect/manage a task source for the workspace. The form is rendered generically
3
+ // from the source's descriptor (credential fields), so the same modal serves Jira
4
+ // and any future credentialed tracker. A credentialless source (GitHub Issues)
5
+ // has no form it rides the workspace's installed GitHub App so the modal just
6
+ // offers the on/off toggle. Secret credentials are write-only: the backend never
7
+ // returns them, so on reload we show "Connected" with empty fields.
8
+ //
9
+ // The on/off toggle is the per-workspace switch (persisted in task_source_settings):
10
+ // a workspace can offer GitHub repos without offering their issues, and can park a
11
+ // connected Jira without disconnecting it. The toggle only applies once a source is
12
+ // available (Jira connected / the GitHub App installed) — there is nothing to offer
13
+ // before that.
7
14
  const ui = useUiStore()
8
15
  const tasks = useTasksStore()
9
16
  const toast = useToast()
@@ -12,6 +19,10 @@ const source = computed(() => ui.taskConnect?.source ?? null)
12
19
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
13
20
  const connection = computed(() => (source.value ? tasks.connectionFor(source.value) : undefined))
14
21
  const connected = computed(() => connection.value !== undefined)
22
+ // A credentialless source (GitHub Issues) reuses the installed GitHub App: no form.
23
+ const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
24
+ // Usable right now: a credentialed source is connected; GitHub Issues' App is installed.
25
+ const available = computed(() => descriptor.value?.available ?? false)
15
26
 
16
27
  const open = computed({
17
28
  get: () => ui.taskConnect !== null,
@@ -23,24 +34,19 @@ const open = computed({
23
34
  /** One value per credential field, reset whenever the modal (re)opens. */
24
35
  const values = ref<Record<string, string>>({})
25
36
  const saving = ref(false)
37
+ const togglingEnabled = ref(false)
26
38
 
27
39
  watch(open, (isOpen) => {
28
40
  if (isOpen) values.value = {}
29
41
  })
30
42
 
31
- // A source with no credential fields (e.g. GitHub, which reuses the workspace's
32
- // installed GitHub App) connects with an empty bag — there is nothing to fill in,
33
- // so the button is enabled as long as it isn't already connected.
34
- const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
35
-
36
43
  const canSubmit = computed(() => {
37
44
  const fields = descriptor.value?.credentialFields ?? []
38
- if (credentialless.value) return !connected.value
39
45
  return fields.every((f) => (values.value[f.key] ?? '').trim())
40
46
  })
41
47
 
42
48
  async function submit() {
43
- if (!canSubmit.value || !source.value) return
49
+ if (!canSubmit.value || !source.value || credentialless.value) return
44
50
  const credentials: Record<string, string> = {}
45
51
  for (const f of descriptor.value!.credentialFields) {
46
52
  credentials[f.key] = values.value[f.key]!.trim()
@@ -53,7 +59,8 @@ async function submit() {
53
59
  icon: 'i-lucide-check',
54
60
  color: 'success',
55
61
  })
56
- ui.closeTaskConnect()
62
+ // Re-probe so `available`/`enabled` reflect the new connection.
63
+ await tasks.probe()
57
64
  } catch (e) {
58
65
  toast.add({
59
66
  title: 'Could not connect',
@@ -69,28 +76,53 @@ async function submit() {
69
76
  async function disconnect() {
70
77
  if (!source.value) return
71
78
  await tasks.disconnect(source.value)
79
+ await tasks.probe()
72
80
  toast.add({
73
81
  title: `${descriptor.value?.label ?? 'Source'} disconnected`,
74
82
  icon: 'i-lucide-unplug',
75
83
  })
76
84
  ui.closeTaskConnect()
77
85
  }
86
+
87
+ async function toggleEnabled(enabled: boolean) {
88
+ if (!source.value) return
89
+ togglingEnabled.value = true
90
+ try {
91
+ await tasks.setEnabled(source.value, enabled)
92
+ } catch (e) {
93
+ toast.add({
94
+ title: 'Could not update',
95
+ description: e instanceof Error ? e.message : String(e),
96
+ icon: 'i-lucide-triangle-alert',
97
+ color: 'error',
98
+ })
99
+ } finally {
100
+ togglingEnabled.value = false
101
+ }
102
+ }
78
103
  </script>
79
104
 
80
105
  <template>
81
- <UModal v-model:open="open" :title="descriptor?.label ?? 'Connect source'">
106
+ <UModal v-model:open="open" :title="descriptor?.label ?? 'Task source'">
82
107
  <template #body>
83
108
  <div v-if="descriptor" class="space-y-4">
84
109
  <p class="text-sm text-slate-400">
85
- Connect {{ descriptor.label }} to import issues and attach them to tasks as agent context.
110
+ {{ descriptor.label }} lets you import issues and attach them to tasks as agent context.
86
111
  </p>
87
112
 
88
- <p v-if="credentialless" class="text-[11px] text-slate-500">
89
- This source uses the GitHub App already installed on your workspace — there are no
90
- credentials to enter. Connecting just enables linking its issues to tasks.
91
- </p>
113
+ <!-- Credentialless source (GitHub Issues): no form, just the on/off toggle. -->
114
+ <template v-if="credentialless">
115
+ <p class="text-[11px] text-slate-500">
116
+ This source uses the GitHub App already installed on your workspace — there are no
117
+ credentials to enter.
118
+ </p>
119
+ <p v-if="!available" class="text-[11px] text-amber-400">
120
+ Install the workspace's GitHub App (connect GitHub repos) to offer {{ descriptor.label }}.
121
+ </p>
122
+ </template>
92
123
 
93
- <div v-else class="space-y-3">
124
+ <!-- Credentialed source (Jira): the connect form, shown until connected. -->
125
+ <div v-else-if="!connected" class="space-y-3">
94
126
  <UFormField
95
127
  v-for="field in descriptor.credentialFields"
96
128
  :key="field.key"
@@ -105,6 +137,27 @@ async function disconnect() {
105
137
  />
106
138
  </UFormField>
107
139
  </div>
140
+ <p v-else class="text-[11px] text-slate-500">
141
+ Connected{{ connection?.label ? ` to ${connection.label}` : '' }}.
142
+ </p>
143
+
144
+ <!-- The per-workspace on/off toggle, available once the source is usable. -->
145
+ <div
146
+ v-if="available"
147
+ class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
148
+ >
149
+ <div class="text-sm">
150
+ <div class="font-medium text-slate-200">Offer to this workspace</div>
151
+ <div class="text-[11px] text-slate-500">
152
+ When off, {{ descriptor.label }} is hidden from import and linking.
153
+ </div>
154
+ </div>
155
+ <USwitch
156
+ :model-value="descriptor.enabled"
157
+ :loading="togglingEnabled"
158
+ @update:model-value="toggleEnabled"
159
+ />
160
+ </div>
108
161
 
109
162
  <div class="flex items-center justify-between gap-2 pt-1">
110
163
  <UButton
@@ -118,6 +171,7 @@ async function disconnect() {
118
171
  </UButton>
119
172
  <div v-else />
120
173
  <UButton
174
+ v-if="!credentialless"
121
175
  color="primary"
122
176
  icon="i-lucide-plug"
123
177
  :loading="saving"
@@ -3,8 +3,8 @@ import type {
3
3
  SourceTask,
4
4
  TaskConnection,
5
5
  TaskSearchResult,
6
- TaskSourceDescriptor,
7
6
  TaskSourceKind,
7
+ TaskSourceState,
8
8
  } from '~/types/domain'
9
9
  import type { PutTrackerSettingsInput, TrackerSettings } from '~/types/tracker'
10
10
  import type { ApiContext } from './context'
@@ -13,10 +13,17 @@ import type { ApiContext } from './context'
13
13
  export function tasksApi({ http, ws }: ApiContext) {
14
14
  return {
15
15
  // ---- task sources (Jira, …) ------------------------------------------
16
- // The configured trackers + their connect/import metadata. A 503 means the
17
- // integration is off (the store hides its UI on any error here).
16
+ // The configured trackers + their connect/import metadata + the workspace's
17
+ // per-source state (available + enabled). A 503 means the integration is off
18
+ // (the store hides its UI on any error here).
18
19
  listTaskSources: (workspaceId: string) =>
19
- http<{ sources: TaskSourceDescriptor[] }>(`${ws(workspaceId)}/task-sources`),
20
+ http<{ sources: TaskSourceState[] }>(`${ws(workspaceId)}/task-sources`),
21
+
22
+ setTaskSourceEnabled: (workspaceId: string, source: TaskSourceKind, enabled: boolean) =>
23
+ http(`${ws(workspaceId)}/task-sources/${source}/enabled`, {
24
+ method: 'PUT',
25
+ body: { enabled },
26
+ }),
20
27
 
21
28
  listTaskConnections: (workspaceId: string) =>
22
29
  http<{ connections: TaskConnection[] }>(`${ws(workspaceId)}/task-sources/connections`),
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
- import type { SourceTask, TaskConnection, TaskSourceDescriptor } from '~/types/domain'
2
+ import type { SourceTask, TaskConnection, TaskSourceState } from '~/types/domain'
3
3
  import { useTasksStore } from '~/stores/tasks'
4
4
 
5
5
  /** Minimal SourceTask factory — only the fields the read getters care about. */
@@ -23,13 +23,15 @@ function sourceTask(externalId: string, over: Partial<SourceTask> = {}): SourceT
23
23
  }
24
24
  }
25
25
 
26
- const jiraDescriptor: TaskSourceDescriptor = {
26
+ const jiraDescriptor: TaskSourceState = {
27
27
  source: 'jira',
28
28
  label: 'Jira',
29
29
  icon: 'i-lucide-square-check',
30
30
  credentialFields: [],
31
31
  refLabel: 'Issue key or URL',
32
32
  refPlaceholder: 'PROJ-123',
33
+ available: true,
34
+ enabled: true,
33
35
  }
34
36
 
35
37
  const jiraConnection: TaskConnection = { source: 'jira', label: 'acme', connectedAt: 0 }
@@ -4,8 +4,8 @@ import type {
4
4
  SourceTask,
5
5
  TaskConnection,
6
6
  TaskSearchResult,
7
- TaskSourceDescriptor,
8
7
  TaskSourceKind,
8
+ TaskSourceState,
9
9
  } from '~/types/domain'
10
10
  import { useWorkspaceStore } from '~/stores/workspace'
11
11
 
@@ -27,9 +27,9 @@ export const useTasksStore = defineStore('tasks', () => {
27
27
 
28
28
  /** null = unknown (not probed yet), true/false = integration on/off. */
29
29
  const available = ref<boolean | null>(null)
30
- /** The configured sources and their connect/import descriptors. */
31
- const sources = ref<TaskSourceDescriptor[]>([])
32
- /** Live connections, one per connected source. */
30
+ /** The configured sources, each with its descriptor + per-workspace state (available + enabled). */
31
+ const sources = ref<TaskSourceState[]>([])
32
+ /** Live connections, one per connected (credentialed) source. */
33
33
  const connections = ref<TaskConnection[]>([])
34
34
  const tasks = ref<SourceTask[]>([])
35
35
  const loading = ref(false)
@@ -40,7 +40,11 @@ export const useTasksStore = defineStore('tasks', () => {
40
40
  )
41
41
  const anyConnected = computed(() => connections.value.length > 0)
42
42
 
43
- function descriptorFor(source: TaskSourceKind): TaskSourceDescriptor | undefined {
43
+ /** Sources offered for import: available (connected / App installed) AND enabled. */
44
+ const offeredSources = computed(() => sources.value.filter((s) => s.available && s.enabled))
45
+ const anyOffered = computed(() => offeredSources.value.length > 0)
46
+
47
+ function descriptorFor(source: TaskSourceKind): TaskSourceState | undefined {
44
48
  return sources.value.find((s) => s.source === source)
45
49
  }
46
50
 
@@ -104,6 +108,13 @@ export const useTasksStore = defineStore('tasks', () => {
104
108
  connections.value = connections.value.filter((c) => c.source !== source)
105
109
  }
106
110
 
111
+ /** Enable or disable a source for the workspace (the per-workspace toggle). */
112
+ async function setEnabled(source: TaskSourceKind, enabled: boolean) {
113
+ await api.setTaskSourceEnabled(workspace.requireId(), source, enabled)
114
+ const i = sources.value.findIndex((s) => s.source === source)
115
+ if (i >= 0) sources.value[i] = { ...sources.value[i]!, enabled }
116
+ }
117
+
107
118
  /** Load the imported issues for the workspace (across sources). */
108
119
  async function loadTasks() {
109
120
  tasks.value = await api.listTasks(workspace.requireId())
@@ -160,6 +171,8 @@ export const useTasksStore = defineStore('tasks', () => {
160
171
  loading,
161
172
  connectedSources,
162
173
  anyConnected,
174
+ offeredSources,
175
+ anyOffered,
163
176
  descriptorFor,
164
177
  connectionFor,
165
178
  isConnected,
@@ -167,6 +180,7 @@ export const useTasksStore = defineStore('tasks', () => {
167
180
  probe,
168
181
  connect,
169
182
  disconnect,
183
+ setEnabled,
170
184
  loadTasks,
171
185
  importTask,
172
186
  search,
@@ -26,6 +26,17 @@ export interface TaskSourceDescriptor {
26
26
  searchable?: boolean
27
27
  }
28
28
 
29
+ /**
30
+ * A source's descriptor plus the workspace's live state: whether it's usable now
31
+ * (`available` — a credentialed source is connected; GitHub Issues' App is
32
+ * installed) and whether the workspace offers it (`enabled`, the per-workspace
33
+ * toggle, default true). `available && enabled` is what makes a source offered.
34
+ */
35
+ export interface TaskSourceState extends TaskSourceDescriptor {
36
+ available: boolean
37
+ enabled: boolean
38
+ }
39
+
29
40
  /** A workspace's connection to a task source (never carries credentials). */
30
41
  export interface TaskConnection {
31
42
  source: TaskSourceKind
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",