arkaos 2.83.0 → 2.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.83.0
1
+ 2.85.0
@@ -1,76 +1,194 @@
1
1
  <script setup lang="ts">
2
+ // PR68 v2.85.0 — Commands page: ▶ Copy + ★ Favorites.
3
+ //
4
+ // The previous Commands page was a read-only catalogue (135 rows,
5
+ // search, filter, expand for keywords). Daniel Ek's audit question
6
+ // landed: "what is the job-to-be-done here vs the CLI?" The answer:
7
+ // fast lookup → copy to clipboard → paste back into Claude Code.
8
+ // PR68 makes that flow one-click + adds operator-curated favorites
9
+ // stored locally so the top-of-list is the operator's actual
10
+ // muscle-memory commands.
11
+
2
12
  import type { TableColumn } from '@nuxt/ui'
3
13
  import type { Command } from '~/types'
4
14
 
5
15
  const { fetchApi } = useApi()
16
+ const toast = useToast()
6
17
 
7
- const { data, status, error, refresh } = await fetchApi<{ commands: Command[], total: number }>('/api/commands')
18
+ const {
19
+ data,
20
+ status,
21
+ error,
22
+ refresh,
23
+ } = await fetchApi<{ commands: Command[], total: number }>('/api/commands')
8
24
 
9
- const commands = computed(() => data.value?.commands ?? [])
25
+ // ─── Favorites (persisted in localStorage) ───────────────────────────────
26
+
27
+ const FAVORITES_KEY = 'arkaos_command_favorites'
28
+ const favorites = ref<Set<string>>(new Set())
29
+
30
+ function loadFavorites() {
31
+ if (typeof window === 'undefined') return
32
+ try {
33
+ const raw = window.localStorage.getItem(FAVORITES_KEY)
34
+ if (!raw) return
35
+ const parsed = JSON.parse(raw)
36
+ if (Array.isArray(parsed)) {
37
+ favorites.value = new Set(parsed.filter((v): v is string => typeof v === 'string'))
38
+ }
39
+ } catch { /* corrupt JSON — ignore, start fresh */ }
40
+ }
41
+
42
+ function persistFavorites() {
43
+ if (typeof window === 'undefined') return
44
+ try {
45
+ window.localStorage.setItem(
46
+ FAVORITES_KEY,
47
+ JSON.stringify(Array.from(favorites.value)),
48
+ )
49
+ } catch { /* quota / disabled storage — silent */ }
50
+ }
51
+
52
+ function toggleFavorite(commandId: string) {
53
+ if (favorites.value.has(commandId)) {
54
+ favorites.value.delete(commandId)
55
+ } else {
56
+ favorites.value.add(commandId)
57
+ }
58
+ persistFavorites()
59
+ }
60
+
61
+ onMounted(() => {
62
+ loadFavorites()
63
+ })
64
+
65
+ // ─── Filters + view ─────────────────────────────────────────────────────
10
66
 
11
67
  const search = ref('')
12
68
  const departmentFilter = ref('all')
69
+ const view = ref<'all' | 'favorites'>('all')
13
70
  const page = ref(1)
14
71
  const pageSize = 20
15
72
  const expandedRow = ref<string | null>(null)
16
73
 
74
+ const commands = computed(() => data.value?.commands ?? [])
75
+
17
76
  const departments = computed(() => {
18
- const depts = new Set(commands.value.map(c => c.department))
77
+ const depts = new Set(commands.value.map((c) => c.department))
19
78
  return [
20
79
  { label: 'All Departments', value: 'all' },
21
- ...Array.from(depts).sort().map(d => ({ label: d, value: d }))
80
+ ...Array.from(depts).sort().map((d) => ({ label: d, value: d })),
22
81
  ]
23
82
  })
24
83
 
25
- const filteredCommands = computed(() => {
26
- let result = commands.value
27
- const query = search.value.toLowerCase()
84
+ const baseList = computed<Command[]>(() => {
85
+ if (view.value === 'favorites') {
86
+ return commands.value.filter((c) => favorites.value.has(c.id))
87
+ }
88
+ return commands.value
89
+ })
28
90
 
91
+ const filteredCommands = computed<Command[]>(() => {
92
+ let result = baseList.value
93
+ const query = search.value.toLowerCase().trim()
29
94
  if (query) {
30
- result = result.filter(cmd =>
95
+ result = result.filter((cmd) =>
31
96
  cmd.command.toLowerCase().includes(query)
32
- || cmd.description.toLowerCase().includes(query)
97
+ || cmd.description.toLowerCase().includes(query),
33
98
  )
34
99
  }
35
-
36
100
  if (departmentFilter.value !== 'all') {
37
- result = result.filter(cmd => cmd.department === departmentFilter.value)
101
+ result = result.filter((cmd) => cmd.department === departmentFilter.value)
102
+ }
103
+ // Favorites pinned on top in the "all" view; the favorites-only
104
+ // view doesn't need re-sorting.
105
+ if (view.value === 'all') {
106
+ result = [...result].sort((a, b) => {
107
+ const aFav = favorites.value.has(a.id) ? 0 : 1
108
+ const bFav = favorites.value.has(b.id) ? 0 : 1
109
+ return aFav - bFav
110
+ })
38
111
  }
39
-
40
112
  return result
41
113
  })
42
114
 
43
115
  const totalFiltered = computed(() => filteredCommands.value.length)
44
116
 
45
- const paginatedCommands = computed(() => {
46
- const start = (page.value - 1) * pageSize
47
- return filteredCommands.value.slice(start, start + pageSize)
48
- })
117
+ const paginatedCommands = computed(() =>
118
+ filteredCommands.value.slice(
119
+ (page.value - 1) * pageSize,
120
+ page.value * pageSize,
121
+ ),
122
+ )
49
123
 
50
- const totalPages = computed(() => Math.max(1, Math.ceil(totalFiltered.value / pageSize)))
124
+ const totalPages = computed(() =>
125
+ Math.max(1, Math.ceil(totalFiltered.value / pageSize)),
126
+ )
51
127
 
52
- watch([search, departmentFilter], () => {
128
+ watch([search, departmentFilter, view], () => {
53
129
  page.value = 1
54
130
  })
55
131
 
132
+ // ─── Copy command to clipboard ──────────────────────────────────────────
133
+
134
+ const copied = ref<string | null>(null)
135
+ let copyTimer: ReturnType<typeof setTimeout> | null = null
136
+
137
+ async function copyCommand(cmd: Command) {
138
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
139
+ toast.add({
140
+ title: 'Clipboard unavailable',
141
+ description: 'Your browser blocked navigator.clipboard.',
142
+ color: 'warning',
143
+ })
144
+ return
145
+ }
146
+ try {
147
+ await navigator.clipboard.writeText(cmd.command)
148
+ copied.value = cmd.id
149
+ if (copyTimer) clearTimeout(copyTimer)
150
+ copyTimer = setTimeout(() => {
151
+ copied.value = null
152
+ copyTimer = null
153
+ }, 1500)
154
+ toast.add({
155
+ title: 'Copied',
156
+ description: cmd.command,
157
+ color: 'success',
158
+ })
159
+ } catch (err) {
160
+ toast.add({
161
+ title: 'Copy failed',
162
+ description: err instanceof Error ? err.message : 'unknown error',
163
+ color: 'error',
164
+ })
165
+ }
166
+ }
167
+
168
+ onBeforeUnmount(() => {
169
+ if (copyTimer) clearTimeout(copyTimer)
170
+ })
171
+
172
+ // ─── Expansion + table ──────────────────────────────────────────────────
173
+
56
174
  function toggleExpand(commandId: string) {
57
175
  expandedRow.value = expandedRow.value === commandId ? null : commandId
58
176
  }
59
177
 
60
178
  const columns: TableColumn<Command>[] = [
61
- {
62
- accessorKey: 'command',
63
- header: 'Command'
64
- },
65
- {
66
- accessorKey: 'department',
67
- header: 'Department'
68
- },
69
- {
70
- accessorKey: 'description',
71
- header: 'Description'
72
- }
179
+ { accessorKey: 'star', header: '' },
180
+ { accessorKey: 'command', header: 'Command' },
181
+ { accessorKey: 'department', header: 'Department' },
182
+ { accessorKey: 'description', header: 'Description' },
183
+ { accessorKey: 'actions', header: '' },
184
+ ]
185
+
186
+ const viewTabs = [
187
+ { label: 'All', value: 'all' as const },
188
+ { label: 'Favorites', value: 'favorites' as const },
73
189
  ]
190
+
191
+ const favoritesCount = computed(() => favorites.value.size)
74
192
  </script>
75
193
 
76
194
  <template>
@@ -81,33 +199,43 @@ const columns: TableColumn<Command>[] = [
81
199
  <UDashboardSidebarCollapse />
82
200
  </template>
83
201
  <template #trailing>
84
- <UBadge v-if="data?.total" :label="data.total" variant="subtle" />
202
+ <UBadge
203
+ v-if="data?.total"
204
+ :label="`${data.total} total`"
205
+ variant="subtle"
206
+ size="xs"
207
+ />
208
+ <UBadge
209
+ v-if="favoritesCount"
210
+ :label="`★ ${favoritesCount}`"
211
+ variant="subtle"
212
+ color="warning"
213
+ size="xs"
214
+ class="ml-2"
215
+ />
85
216
  </template>
86
217
  </UDashboardNavbar>
87
218
  </template>
88
219
 
89
220
  <template #body>
90
- <!-- Loading -->
91
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
92
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
93
- </div>
94
-
95
- <!-- Error -->
96
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
97
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
98
- <p class="text-sm text-muted">Failed to load commands.</p>
99
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
100
- </div>
101
-
102
- <!-- Empty -->
103
- <div v-else-if="!commands.length" class="flex flex-col items-center justify-center gap-4 py-12">
104
- <UIcon name="i-lucide-terminal" class="size-12 text-muted" />
105
- <p class="text-sm text-muted">No commands found.</p>
106
- </div>
107
-
108
- <!-- Content -->
109
- <template v-else>
221
+ <DashboardState
222
+ :status="status"
223
+ :error="error"
224
+ :empty="!commands.length"
225
+ empty-title="No commands found"
226
+ empty-icon="i-lucide-terminal"
227
+ loading-label="Loading commands"
228
+ :on-retry="() => refresh()"
229
+ >
230
+ <!-- View tabs + filters -->
110
231
  <div class="flex flex-wrap items-center gap-3 mb-4">
232
+ <UTabs
233
+ :items="viewTabs"
234
+ :model-value="view"
235
+ class="shrink-0"
236
+ @update:model-value="view = $event as 'all' | 'favorites'"
237
+ />
238
+
111
239
  <UInput
112
240
  v-model="search"
113
241
  class="max-w-sm"
@@ -130,51 +258,98 @@ const columns: TableColumn<Command>[] = [
130
258
  </span>
131
259
  </div>
132
260
 
133
- <UTable
134
- :data="paginatedCommands"
135
- :columns="columns"
136
- :loading="status === 'pending'"
137
- class="shrink-0"
138
- :ui="{
139
- base: 'table-fixed border-separate border-spacing-0',
140
- thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
141
- tbody: '[&>tr]:last:[&>td]:border-b-0 [&>tr]:cursor-pointer [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
142
- th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
143
- td: 'border-b border-default'
144
- }"
145
- @select="(row: Command) => toggleExpand(row.id)"
261
+ <!-- Favorites empty state -->
262
+ <div
263
+ v-if="view === 'favorites' && favoritesCount === 0"
264
+ class="flex flex-col items-center justify-center gap-3 py-16 rounded-lg border border-default"
146
265
  >
147
- <template #command-cell="{ row }">
148
- <code class="font-mono text-sm text-primary">{{ row.original.command }}</code>
149
- </template>
150
- <template #department-cell="{ row }">
151
- <UBadge :label="row.original.department" variant="subtle" size="sm" />
152
- </template>
153
- <template #expanded="{ row }">
154
- <div v-if="expandedRow === row.original.id && row.original.keywords?.length" class="px-4 py-3 bg-elevated/30">
155
- <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">Keywords</p>
156
- <div class="flex flex-wrap gap-1.5">
157
- <UBadge
158
- v-for="kw in row.original.keywords"
159
- :key="kw"
160
- :label="kw"
161
- variant="outline"
162
- size="xs"
163
- />
164
- </div>
165
- </div>
166
- </template>
167
- </UTable>
168
-
169
- <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
170
- <UPagination
171
- :page="page"
172
- :total="totalFiltered"
173
- :items-per-page="pageSize"
174
- @update:page="(val) => page = val"
175
- />
266
+ <UIcon name="i-lucide-star" class="size-12 text-muted" />
267
+ <p class="text-sm text-muted">No favorites yet.</p>
268
+ <p class="text-xs text-muted text-center max-w-sm">
269
+ Click the ★ next to any command to pin it here for one-click access.
270
+ </p>
176
271
  </div>
177
- </template>
272
+
273
+ <!-- Table -->
274
+ <template v-else>
275
+ <UTable
276
+ :data="paginatedCommands"
277
+ :columns="columns"
278
+ :loading="status === 'pending'"
279
+ class="shrink-0"
280
+ :ui="{
281
+ base: 'table-fixed border-separate border-spacing-0',
282
+ thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
283
+ tbody: '[&>tr]:last:[&>td]:border-b-0 [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
284
+ th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
285
+ td: 'border-b border-default',
286
+ }"
287
+ >
288
+ <template #star-cell="{ row }">
289
+ <UButton
290
+ :icon="favorites.has(row.original.id) ? 'i-lucide-star' : 'i-lucide-star'"
291
+ :color="favorites.has(row.original.id) ? 'warning' : 'neutral'"
292
+ variant="ghost"
293
+ size="xs"
294
+ :aria-label="favorites.has(row.original.id) ? 'Unfavorite' : 'Favorite'"
295
+ :class="favorites.has(row.original.id) ? '' : 'opacity-30 hover:opacity-100'"
296
+ @click.stop="toggleFavorite(row.original.id)"
297
+ />
298
+ </template>
299
+ <template #command-cell="{ row }">
300
+ <button
301
+ type="button"
302
+ class="text-left w-full"
303
+ @click="toggleExpand(row.original.id)"
304
+ >
305
+ <code class="font-mono text-sm text-primary">{{ row.original.command }}</code>
306
+ </button>
307
+ </template>
308
+ <template #department-cell="{ row }">
309
+ <UBadge :label="row.original.department" variant="subtle" size="sm" />
310
+ </template>
311
+ <template #description-cell="{ row }">
312
+ <span class="text-sm text-muted">{{ row.original.description }}</span>
313
+ </template>
314
+ <template #actions-cell="{ row }">
315
+ <UButton
316
+ :icon="copied === row.original.id ? 'i-lucide-check' : 'i-lucide-copy'"
317
+ :color="copied === row.original.id ? 'success' : 'neutral'"
318
+ variant="ghost"
319
+ size="xs"
320
+ aria-label="Copy command to clipboard"
321
+ @click.stop="copyCommand(row.original)"
322
+ />
323
+ </template>
324
+ <template #expanded="{ row }">
325
+ <div
326
+ v-if="expandedRow === row.original.id && row.original.keywords?.length"
327
+ class="px-4 py-3 bg-elevated/30"
328
+ >
329
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">Keywords</p>
330
+ <div class="flex flex-wrap gap-1.5">
331
+ <UBadge
332
+ v-for="kw in row.original.keywords"
333
+ :key="kw"
334
+ :label="kw"
335
+ variant="outline"
336
+ size="xs"
337
+ />
338
+ </div>
339
+ </div>
340
+ </template>
341
+ </UTable>
342
+
343
+ <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
344
+ <UPagination
345
+ :page="page"
346
+ :total="totalFiltered"
347
+ :items-per-page="pageSize"
348
+ @update:page="(val) => page = val"
349
+ />
350
+ </div>
351
+ </template>
352
+ </DashboardState>
178
353
  </template>
179
354
  </UDashboardPanel>
180
355
  </template>
@@ -1,85 +1,227 @@
1
1
  <script setup lang="ts">
2
+ // PR67 v2.84.0 — Tasks page rewritten against /api/jobs.
3
+ //
4
+ // The legacy /api/tasks endpoint returns the deprecated TaskManager
5
+ // data, which has no live updates and no cancel. The SQLite job queue
6
+ // (/api/jobs) is the real workhorse — knowledge ingest fans out into
7
+ // jobs, WebSocket broadcasts every progress event, DELETE /api/jobs/{id}
8
+ // cancels queued work. This page now consumes the real thing.
9
+ //
10
+ // What changed vs the previous tasks.vue:
11
+ // - Polls /api/jobs (jobs are the new tasks)
12
+ // - Subscribes to /ws/tasks for live progress + status flips
13
+ // - Cancel button on queued/processing rows
14
+ // - Empty state suggests a real command (Knowledge → ingest) instead
15
+ // of the dead-link "npx arkaos index" hint
16
+ // - DashboardState (PR64) wraps loading/error/empty consistently
17
+
2
18
  import type { TableColumn } from '@nuxt/ui'
3
- import type { Task, TaskSummary } from '~/types'
19
+ import type { Job, JobSummary } from '~/types'
20
+
21
+ const { fetchApi, apiBase } = useApi()
22
+ const toast = useToast()
23
+
24
+ const {
25
+ data,
26
+ status,
27
+ error,
28
+ refresh,
29
+ } = await fetchApi<{ jobs: Job[], summary: JobSummary }>('/api/jobs?limit=200')
4
30
 
5
- const { fetchApi } = useApi()
31
+ const jobs = ref<Job[]>(data.value?.jobs ?? [])
32
+ const summary = ref<JobSummary>(data.value?.summary ?? {
33
+ total: 0, queued: 0, processing: 0, completed: 0, failed: 0,
34
+ })
6
35
 
7
- const { data, status, error, refresh } = await fetchApi<{ tasks: Task[], summary: TaskSummary }>('/api/tasks')
36
+ watch(data, (d) => {
37
+ if (!d) return
38
+ jobs.value = d.jobs ?? []
39
+ summary.value = d.summary ?? {
40
+ total: 0, queued: 0, processing: 0, completed: 0, failed: 0,
41
+ }
42
+ })
8
43
 
9
- const tasks = computed(() => data.value?.tasks ?? [])
10
- const summary = computed(() => data.value?.summary ?? { total: 0, active: 0, queued: 0, completed: 0 })
44
+ // ─── Filter tabs ─────────────────────────────────────────────────────────
11
45
 
12
- const activeTab = ref('all')
46
+ const activeTab = ref<'all' | 'active' | 'queued' | 'completed' | 'failed'>('all')
13
47
 
14
48
  const tabItems = [
15
- { label: 'All', value: 'all' },
16
- { label: 'Active', value: 'active' },
17
- { label: 'Queued', value: 'queued' },
18
- { label: 'Completed', value: 'completed' },
19
- { label: 'Failed', value: 'failed' }
49
+ { label: 'All', value: 'all' as const },
50
+ { label: 'Active', value: 'active' as const },
51
+ { label: 'Queued', value: 'queued' as const },
52
+ { label: 'Completed', value: 'completed' as const },
53
+ { label: 'Failed', value: 'failed' as const },
20
54
  ]
21
55
 
22
- const filteredTasks = computed(() => {
23
- if (activeTab.value === 'all') return tasks.value
24
- return tasks.value.filter(t => {
25
- if (activeTab.value === 'active') return t.status === 'processing' || t.status === 'active'
26
- return t.status === activeTab.value
27
- })
56
+ const ACTIVE_STATUSES: Job['status'][] = [
57
+ 'processing', 'downloading', 'transcribing', 'embedding',
58
+ ]
59
+
60
+ const filteredJobs = computed<Job[]>(() => {
61
+ if (activeTab.value === 'all') return jobs.value
62
+ if (activeTab.value === 'active') {
63
+ return jobs.value.filter((j) => ACTIVE_STATUSES.includes(j.status))
64
+ }
65
+ return jobs.value.filter((j) => j.status === activeTab.value)
66
+ })
67
+
68
+ const CANCELLABLE: Job['status'][] = ['queued', 'processing']
69
+ function isCancellable(job: Job): boolean {
70
+ return CANCELLABLE.includes(job.status)
71
+ }
72
+
73
+ // ─── WebSocket — live updates ────────────────────────────────────────────
74
+
75
+ let ws: WebSocket | null = null
76
+ const wsConnected = ref(false)
77
+
78
+ function connectWebSocket() {
79
+ if (ws && ws.readyState === WebSocket.OPEN) return
80
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
81
+ ws = new WebSocket(wsUrl)
82
+ ws.onopen = () => { wsConnected.value = true }
83
+ ws.onclose = () => { wsConnected.value = false }
84
+ ws.onerror = () => { wsConnected.value = false }
85
+ ws.onmessage = (event) => {
86
+ try {
87
+ const msg = JSON.parse(event.data) as {
88
+ type: string
89
+ job_id?: string
90
+ progress?: number
91
+ message?: string
92
+ status?: Job['status']
93
+ error?: string
94
+ chunks_created?: number
95
+ }
96
+ if (!msg.job_id) return
97
+ const idx = jobs.value.findIndex((j) => j.id === msg.job_id)
98
+ if (idx === -1) {
99
+ // We don't have the row yet (new job spawned mid-session).
100
+ // Refetch the list so it shows up.
101
+ refresh()
102
+ return
103
+ }
104
+ const current = jobs.value[idx]
105
+ if (!current) return
106
+ const next: Job = { ...current }
107
+ if (msg.type === 'job_progress') {
108
+ if (typeof msg.progress === 'number') next.progress = msg.progress
109
+ if (typeof msg.message === 'string') next.message = msg.message
110
+ if (msg.status) next.status = msg.status
111
+ } else if (msg.type === 'job_complete') {
112
+ next.status = 'completed'
113
+ next.progress = 100
114
+ if (typeof msg.chunks_created === 'number') next.chunks_created = msg.chunks_created
115
+ } else if (msg.type === 'job_failed') {
116
+ next.status = 'failed'
117
+ if (typeof msg.error === 'string') next.error = msg.error
118
+ } else if (msg.type === 'job_cancelled') {
119
+ next.status = 'cancelled'
120
+ }
121
+ jobs.value.splice(idx, 1, next)
122
+ } catch {
123
+ /* ignore malformed messages */
124
+ }
125
+ }
126
+ }
127
+
128
+ function disconnectWebSocket() {
129
+ if (!ws) return
130
+ try { ws.close() } catch { /* already closed */ }
131
+ ws = null
132
+ wsConnected.value = false
133
+ }
134
+
135
+ onMounted(() => {
136
+ connectWebSocket()
137
+ })
138
+
139
+ onBeforeUnmount(() => {
140
+ disconnectWebSocket()
28
141
  })
29
142
 
30
- type StatusColorType = 'success' | 'error' | 'primary' | 'warning' | 'neutral'
143
+ // ─── Cancel ──────────────────────────────────────────────────────────────
144
+
145
+ const cancelling = ref<Set<string>>(new Set())
31
146
 
32
- function statusColor(taskStatus: string): StatusColorType {
33
- const colors: Record<string, StatusColorType> = {
34
- completed: 'success',
35
- processing: 'primary',
36
- active: 'primary',
37
- queued: 'neutral',
38
- failed: 'error',
39
- cancelled: 'warning'
147
+ async function cancelJob(job: Job) {
148
+ if (!isCancellable(job)) return
149
+ cancelling.value.add(job.id)
150
+ try {
151
+ const res = await $fetch<{ cancelled?: boolean, error?: string }>(
152
+ `${apiBase}/api/jobs/${job.id}`,
153
+ { method: 'DELETE' },
154
+ )
155
+ if (res.cancelled) {
156
+ // WS will broadcast 'job_cancelled' — handler will flip the row.
157
+ // Refresh as a safety net in case the WS message races.
158
+ await refresh()
159
+ toast.add({
160
+ title: 'Job cancelled',
161
+ description: job.source || job.id,
162
+ color: 'success',
163
+ })
164
+ } else {
165
+ toast.add({
166
+ title: 'Cancel rejected',
167
+ description: res.error || 'Only queued jobs can be cancelled.',
168
+ color: 'warning',
169
+ })
170
+ }
171
+ } catch (err) {
172
+ toast.add({
173
+ title: 'Cancel failed',
174
+ description: err instanceof Error ? err.message : 'unknown error',
175
+ color: 'error',
176
+ })
177
+ } finally {
178
+ cancelling.value.delete(job.id)
40
179
  }
41
- return colors[taskStatus] ?? 'neutral'
180
+ }
181
+
182
+ // ─── Render helpers ──────────────────────────────────────────────────────
183
+
184
+ type StatusColor = 'success' | 'error' | 'primary' | 'warning' | 'neutral'
185
+
186
+ const STATUS_COLOR: Record<Job['status'], StatusColor> = {
187
+ queued: 'neutral',
188
+ processing: 'primary',
189
+ downloading: 'primary',
190
+ transcribing: 'primary',
191
+ embedding: 'primary',
192
+ completed: 'success',
193
+ failed: 'error',
194
+ cancelled: 'warning',
195
+ }
196
+
197
+ function statusColor(s: Job['status']): StatusColor {
198
+ return STATUS_COLOR[s] ?? 'neutral'
42
199
  }
43
200
 
44
201
  function formatDate(dateStr: string) {
45
202
  if (!dateStr) return '-'
46
203
  try {
47
204
  return new Intl.DateTimeFormat('en-US', {
48
- month: 'short',
49
- day: 'numeric',
50
- hour: '2-digit',
51
- minute: '2-digit'
205
+ month: 'short', day: 'numeric',
206
+ hour: '2-digit', minute: '2-digit',
52
207
  }).format(new Date(dateStr))
53
208
  } catch {
54
209
  return dateStr
55
210
  }
56
211
  }
57
212
 
58
- const columns: TableColumn<Task>[] = [
59
- {
60
- accessorKey: 'agent',
61
- header: 'Type'
62
- },
63
- {
64
- accessorKey: 'title',
65
- header: 'Title'
66
- },
67
- {
68
- accessorKey: 'department',
69
- header: 'Department'
70
- },
71
- {
72
- accessorKey: 'status',
73
- header: 'Status'
74
- },
75
- {
76
- accessorKey: 'progress',
77
- header: 'Progress'
78
- },
79
- {
80
- accessorKey: 'created_at',
81
- header: 'Created'
82
- }
213
+ function truncate(value: string, max = 60): string {
214
+ if (!value) return ''
215
+ return value.length > max ? value.slice(0, max - 1) + '' : value
216
+ }
217
+
218
+ const columns: TableColumn<Job>[] = [
219
+ { accessorKey: 'type', header: 'Type' },
220
+ { accessorKey: 'source', header: 'Source' },
221
+ { accessorKey: 'status', header: 'Status' },
222
+ { accessorKey: 'progress', header: 'Progress' },
223
+ { accessorKey: 'created_at', header: 'Created' },
224
+ { accessorKey: 'actions', header: '' },
83
225
  ]
84
226
  </script>
85
227
 
@@ -90,6 +232,24 @@ const columns: TableColumn<Task>[] = [
90
232
  <template #leading>
91
233
  <UDashboardSidebarCollapse />
92
234
  </template>
235
+ <template #trailing>
236
+ <UBadge
237
+ :label="wsConnected ? 'Live' : 'Offline'"
238
+ :color="wsConnected ? 'success' : 'neutral'"
239
+ variant="subtle"
240
+ size="xs"
241
+ :icon="wsConnected ? 'i-lucide-radio-tower' : 'i-lucide-radio'"
242
+ />
243
+ </template>
244
+ <template #right>
245
+ <UButton
246
+ label="Refresh"
247
+ variant="ghost"
248
+ icon="i-lucide-refresh-cw"
249
+ size="sm"
250
+ @click="refresh()"
251
+ />
252
+ </template>
93
253
  </UDashboardNavbar>
94
254
  </template>
95
255
 
@@ -97,17 +257,17 @@ const columns: TableColumn<Task>[] = [
97
257
  <DashboardState
98
258
  :status="status"
99
259
  :error="error"
100
- loading-label="Loading tasks"
260
+ loading-label="Loading jobs"
101
261
  :on-retry="() => refresh()"
102
262
  >
103
- <!-- Summary Cards -->
104
- <div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
263
+ <!-- Summary cards -->
264
+ <div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
105
265
  <div class="rounded-lg border border-default p-4 text-center">
106
266
  <p class="text-2xl font-semibold text-highlighted">{{ summary.total }}</p>
107
267
  <p class="text-xs text-muted">Total</p>
108
268
  </div>
109
269
  <div class="rounded-lg border border-default p-4 text-center">
110
- <p class="text-2xl font-semibold text-primary">{{ summary.active }}</p>
270
+ <p class="text-2xl font-semibold text-primary">{{ summary.processing ?? 0 }}</p>
111
271
  <p class="text-xs text-muted">Active</p>
112
272
  </div>
113
273
  <div class="rounded-lg border border-default p-4 text-center">
@@ -118,77 +278,116 @@ const columns: TableColumn<Task>[] = [
118
278
  <p class="text-2xl font-semibold text-green-500">{{ summary.completed }}</p>
119
279
  <p class="text-xs text-muted">Completed</p>
120
280
  </div>
281
+ <div class="rounded-lg border border-default p-4 text-center">
282
+ <p class="text-2xl font-semibold text-red-500">{{ summary.failed }}</p>
283
+ <p class="text-xs text-muted">Failed</p>
284
+ </div>
121
285
  </div>
122
286
 
123
- <!-- Status Filter Tabs -->
287
+ <!-- Filter tabs -->
124
288
  <div class="mt-6">
125
289
  <UTabs
126
290
  :items="tabItems"
127
291
  :model-value="activeTab"
128
- @update:model-value="activeTab = $event as string"
292
+ @update:model-value="activeTab = $event as 'all' | 'active' | 'queued' | 'completed' | 'failed'"
129
293
  />
130
294
  </div>
131
295
 
132
- <!-- Empty State -->
133
- <div v-if="!tasks.length" class="mt-6 flex flex-col items-center justify-center gap-4 py-16">
134
- <UIcon name="i-lucide-list-checks" class="size-16 text-muted" />
135
- <h3 class="text-lg font-semibold text-highlighted">No tasks yet</h3>
136
- <p class="text-sm text-muted text-center max-w-md">
137
- Tasks are created when you run ArkaOS workflows. Start by indexing your project or running a command.
138
- </p>
139
- <div class="mt-2 rounded-lg border border-default bg-elevated/50 px-4 py-3">
140
- <p class="text-xs text-muted mb-1">Try running:</p>
141
- <code class="font-mono text-sm text-primary">npx arkaos index</code>
142
- </div>
143
- </div>
296
+ <!-- Global empty (no jobs at all) -->
297
+ <DashboardState
298
+ :status="status"
299
+ :empty="!jobs.length"
300
+ empty-title="No jobs yet"
301
+ empty-icon="i-lucide-list-checks"
302
+ >
303
+ <template #empty>
304
+ <UIcon name="i-lucide-list-checks" class="size-16 text-muted" />
305
+ <h3 class="text-lg font-semibold text-highlighted">No jobs yet</h3>
306
+ <p class="text-sm text-muted text-center max-w-md">
307
+ Jobs are created when you ingest content. Head to the Knowledge tab and paste a URL or upload a file — each one runs as a job here in real time.
308
+ </p>
309
+ <UButton
310
+ to="/knowledge"
311
+ label="Open Knowledge"
312
+ icon="i-lucide-brain"
313
+ size="md"
314
+ class="mt-2"
315
+ />
316
+ </template>
144
317
 
145
- <!-- Filtered Empty -->
146
- <div v-else-if="!filteredTasks.length" class="mt-6 flex flex-col items-center justify-center gap-4 py-12">
147
- <UIcon name="i-lucide-filter-x" class="size-12 text-muted" />
148
- <p class="text-sm text-muted">No {{ activeTab }} tasks found.</p>
149
- </div>
150
-
151
- <!-- Task Table -->
152
- <div v-else class="mt-4">
153
- <UTable
154
- :data="filteredTasks"
155
- :columns="columns"
156
- :loading="status === 'pending'"
157
- class="shrink-0"
158
- :ui="{
159
- base: 'table-fixed border-separate border-spacing-0',
160
- thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
161
- tbody: '[&>tr]:last:[&>td]:border-b-0',
162
- th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
163
- td: 'border-b border-default'
164
- }"
318
+ <!-- Filtered empty (jobs exist but not in this tab) -->
319
+ <div
320
+ v-if="!filteredJobs.length"
321
+ class="mt-6 flex flex-col items-center justify-center gap-4 py-12"
165
322
  >
166
- <template #agent-cell="{ row }">
167
- <UBadge :label="row.original.agent" variant="subtle" color="primary" size="sm" />
168
- </template>
169
- <template #department-cell="{ row }">
170
- <span class="text-sm text-muted">{{ row.original.department }}</span>
171
- </template>
172
- <template #status-cell="{ row }">
173
- <UBadge
174
- :label="row.original.status"
175
- :color="statusColor(row.original.status)"
176
- variant="subtle"
177
- size="sm"
178
- class="capitalize"
179
- />
180
- </template>
181
- <template #progress-cell="{ row }">
182
- <div class="flex items-center gap-2 min-w-24">
183
- <UProgress :value="row.original.progress" :max="100" size="xs" class="flex-1" />
184
- <span class="text-xs text-muted font-mono w-8 text-right">{{ row.original.progress }}%</span>
185
- </div>
186
- </template>
187
- <template #created_at-cell="{ row }">
188
- <span class="text-xs text-muted">{{ formatDate(row.original.created_at) }}</span>
189
- </template>
190
- </UTable>
191
- </div>
323
+ <UIcon name="i-lucide-filter-x" class="size-12 text-muted" />
324
+ <p class="text-sm text-muted">No {{ activeTab }} jobs found.</p>
325
+ </div>
326
+
327
+ <!-- Job table -->
328
+ <div v-else class="mt-4">
329
+ <UTable
330
+ :data="filteredJobs"
331
+ :columns="columns"
332
+ :loading="status === 'pending'"
333
+ class="shrink-0"
334
+ :ui="{
335
+ base: 'table-fixed border-separate border-spacing-0',
336
+ thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
337
+ tbody: '[&>tr]:last:[&>td]:border-b-0',
338
+ th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
339
+ td: 'border-b border-default',
340
+ }"
341
+ >
342
+ <template #type-cell="{ row }">
343
+ <UBadge :label="row.original.type || 'unknown'" variant="subtle" color="primary" size="sm" />
344
+ </template>
345
+ <template #source-cell="{ row }">
346
+ <span class="text-sm font-mono" :title="row.original.source">
347
+ {{ truncate(row.original.source, 50) }}
348
+ </span>
349
+ <p v-if="row.original.message" class="text-xs text-muted mt-0.5">
350
+ {{ truncate(row.original.message, 60) }}
351
+ </p>
352
+ <p v-if="row.original.error" class="text-xs text-red-400 mt-0.5">
353
+ {{ truncate(row.original.error, 80) }}
354
+ </p>
355
+ </template>
356
+ <template #status-cell="{ row }">
357
+ <UBadge
358
+ :label="row.original.status"
359
+ :color="statusColor(row.original.status)"
360
+ variant="subtle"
361
+ size="sm"
362
+ class="capitalize"
363
+ />
364
+ </template>
365
+ <template #progress-cell="{ row }">
366
+ <div class="flex items-center gap-2 min-w-24">
367
+ <UProgress :value="row.original.progress" :max="100" size="xs" class="flex-1" />
368
+ <span class="text-xs text-muted font-mono w-8 text-right">
369
+ {{ row.original.progress }}%
370
+ </span>
371
+ </div>
372
+ </template>
373
+ <template #created_at-cell="{ row }">
374
+ <span class="text-xs text-muted">{{ formatDate(row.original.created_at) }}</span>
375
+ </template>
376
+ <template #actions-cell="{ row }">
377
+ <UButton
378
+ v-if="isCancellable(row.original)"
379
+ icon="i-lucide-x"
380
+ variant="ghost"
381
+ color="error"
382
+ size="xs"
383
+ :loading="cancelling.has(row.original.id)"
384
+ aria-label="Cancel job"
385
+ @click="cancelJob(row.original)"
386
+ />
387
+ </template>
388
+ </UTable>
389
+ </div>
390
+ </DashboardState>
192
391
  </DashboardState>
193
392
  </template>
194
393
  </UDashboardPanel>
@@ -95,6 +95,35 @@ export interface TaskSummary {
95
95
  completed: number
96
96
  }
97
97
 
98
+ // PR67 v2.84.0 — Jobs shape from /api/jobs (SQLite job queue).
99
+ // Mirrors core/jobs/manager.Job dataclass.
100
+ export interface Job {
101
+ id: string
102
+ type: string
103
+ source: string
104
+ title: string
105
+ status:
106
+ | 'queued' | 'processing' | 'downloading' | 'transcribing'
107
+ | 'embedding' | 'completed' | 'failed' | 'cancelled'
108
+ progress: number
109
+ message: string
110
+ chunks_created: number
111
+ media_path: string
112
+ error: string
113
+ created_at: string
114
+ started_at: string
115
+ completed_at: string
116
+ }
117
+
118
+ export interface JobSummary {
119
+ total: number
120
+ queued: number
121
+ processing: number
122
+ completed: number
123
+ failed: number
124
+ cancelled?: number
125
+ }
126
+
98
127
  export interface KnowledgeStats {
99
128
  total_chunks: number
100
129
  total_files: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.83.0",
3
+ "version": "2.85.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.83.0"
3
+ version = "2.85.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}