arkaos 2.84.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.84.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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.84.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.84.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"}