hlassiege/panopliepublic Fork
⑂ 8804311
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(ui): visual QA fixes (icons, folder picker, missing project, layout, readability)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:09:51+02:00 Browse files
8804311 parent: 8e6675a
modified .gitignore +1 -0
@@ -5,3 +5,4 @@ dist/
55 .data/
66 *.log
77 .idea/
8+.qa/
@@ -5,3 +5,4 @@ dist/
5 .data/5 .data/
6 *.log6 *.log
7 .idea/7 .idea/
8+.qa/
modified packages/server/src/index.ts +10 -1
@@ -90,6 +90,14 @@ function tokenMatches(given: string | undefined, expected: string): boolean {
9090 return a.length === b.length && timingSafeEqual(a, b)
9191 }
9292
93+async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> {
94+ try {
95+ return (await stat(p)).isDirectory() ? 'dir' : 'file'
96+ } catch {
97+ return 'missing'
98+ }
99+}
100+
93101 async function isDir(p: string): Promise<boolean> {
94102 try {
95103 return (await stat(p)).isDirectory()
@@ -179,7 +187,8 @@ export function createApp(options: AppOptions): Hono {
179187 if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) {
180188 return c.json(err('An absolute "path" is required'), 400)
181189 }
182- if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400)
190+ const kind = await pathKind(body.path)
191+ if (kind !== 'dir') return c.json(err(kind === 'missing' ? 'Folder not found' : 'This path is a file, not a folder'), 400)
183192 const project = await store.add(body.path)
184193 if (!project) return c.json(err('Project already added'), 409)
185194 return c.json(await summarize(project, false), 201)
@@ -90,6 +90,14 @@ function tokenMatches(given: string | undefined, expected: string): boolean {
90 return a.length === b.length && timingSafeEqual(a, b)90 return a.length === b.length && timingSafeEqual(a, b)
91 }91 }
92 92
93+async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> {
94+ try {
95+ return (await stat(p)).isDirectory() ? 'dir' : 'file'
96+ } catch {
97+ return 'missing'
98+ }
99+}
100+
93 async function isDir(p: string): Promise<boolean> {101 async function isDir(p: string): Promise<boolean> {
94 try {102 try {
95 return (await stat(p)).isDirectory()103 return (await stat(p)).isDirectory()
@@ -179,7 +187,8 @@ export function createApp(options: AppOptions): Hono {
179 if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) {187 if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) {
180 return c.json(err('An absolute "path" is required'), 400)188 return c.json(err('An absolute "path" is required'), 400)
181 }189 }
182- if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400)190+ const kind = await pathKind(body.path)
191+ if (kind !== 'dir') return c.json(err(kind === 'missing' ? 'Folder not found' : 'This path is a file, not a folder'), 400)
183 const project = await store.add(body.path)192 const project = await store.add(body.path)
184 if (!project) return c.json(err('Project already added'), 409)193 if (!project) return c.json(err('Project already added'), 409)
185 return c.json(await summarize(project, false), 201)194 return c.json(await summarize(project, false), 201)
modified packages/ui/app/assets/css/main.css +3 -2
@@ -13,6 +13,7 @@
1313 .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }
1414 .md-body pre code { background: transparent; padding: 0; }
1515 .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; }
16-.md-body table { border-collapse: collapse; margin: 0.5rem 0; }
17-.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; }
16+.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
17+.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
18+.md-body th { background: var(--ui-bg-elevated); font-weight: 600; }
1819 .md-body hr { border-color: var(--ui-border); margin: 1rem 0; }
@@ -13,6 +13,7 @@
13 .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }13 .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }
14 .md-body pre code { background: transparent; padding: 0; }14 .md-body pre code { background: transparent; padding: 0; }
15 .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; }15 .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; }
16-.md-body table { border-collapse: collapse; margin: 0.5rem 0; }16+.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
17-.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; }17+.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
18+.md-body th { background: var(--ui-bg-elevated); font-weight: 600; }
18 .md-body hr { border-color: var(--ui-border); margin: 1rem 0; }19 .md-body hr { border-color: var(--ui-border); margin: 1rem 0; }
modified packages/ui/app/components/AddProjectModal.vue +27 -21
@@ -16,6 +16,9 @@ const submitting = ref(false)
1616 const submitError = ref<string | null>(null)
1717 const highlighted = ref<string | null>(null)
1818
19+// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.
20+const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null)
21+
1922 const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
2023
2124 async function browse(path?: string) {
@@ -43,11 +46,7 @@ async function submit(path: string) {
4346 await router.push(`/projects/${encodeURIComponent(created.id)}`)
4447 } catch (e) {
4548 const err = e as { status?: number, message: string }
46- submitError.value = err.status === 409
47- ? 'This project was already added.'
48- : err.status === 400
49- ? `Not a valid folder: ${err.message}`
50- : err.message
49+ submitError.value = err.status === 409 ? 'This project was already added.' : err.message
5150 } finally {
5251 submitting.value = false
5352 }
@@ -114,26 +113,32 @@ watch(open, (v) => {
114113 <span class="text-muted">..</span>
115114 </button>
116115 </li>
117- <li v-for="e in listing.entries" :key="e.path">
116+ <li
117+ v-for="e in listing.entries"
118+ :key="e.path"
119+ class="flex items-center hover:bg-elevated/60"
120+ :class="highlighted === e.path ? 'bg-primary/10' : ''"
121+ >
118122 <button
119123 type="button"
120- class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-elevated/60"
121- :class="highlighted === e.path ? 'bg-elevated' : ''"
124+ class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
125+ :title="e.path"
122126 @click="highlight(e.path)"
123127 @dblclick="browse(e.path)"
124128 >
125- <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-muted" />
129+ <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
126130 <span class="flex-1 truncate">{{ e.name }}</span>
127- <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" />
128- <UButton
129- icon="i-lucide-chevron-right"
130- size="xs"
131- color="neutral"
132- variant="ghost"
133- aria-label="Open folder"
134- @click.stop="browse(e.path)"
135- />
131+ <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
136132 </button>
133+ <UButton
134+ icon="i-lucide-chevron-right"
135+ size="xs"
136+ color="neutral"
137+ variant="ghost"
138+ class="mx-2 shrink-0"
139+ :aria-label="`Open ${e.name}`"
140+ @click="browse(e.path)"
141+ />
137142 </li>
138143 <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
139144 </ul>
@@ -143,15 +148,16 @@ watch(open, (v) => {
143148
144149 <template #footer>
145150 <div class="flex w-full items-center justify-between gap-2">
146- <span class="truncate font-mono text-xs text-muted">{{ listing?.path ?? 'Select a drive or folder' }}</span>
151+ <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span>
152+ <span v-else class="truncate text-xs text-muted">Select a drive or folder</span>
147153 <div class="flex shrink-0 gap-2">
148154 <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
149155 <UButton
150156 label="Select this folder"
151157 icon="i-lucide-check"
152- :disabled="!listing?.path"
158+ :disabled="!selectedPath"
153159 :loading="submitting"
154- @click="submit(listing!.path!)"
160+ @click="submit(selectedPath!)"
155161 />
156162 </div>
157163 </div>
@@ -16,6 +16,9 @@ const submitting = ref(false)
16 const submitError = ref<string | null>(null)16 const submitError = ref<string | null>(null)
17 const highlighted = ref<string | null>(null)17 const highlighted = ref<string | null>(null)
18 18
19+// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.
20+const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null)
21+
19 const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))22 const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : []))
20 23
21 async function browse(path?: string) {24 async function browse(path?: string) {
@@ -43,11 +46,7 @@ async function submit(path: string) {
43 await router.push(`/projects/${encodeURIComponent(created.id)}`)46 await router.push(`/projects/${encodeURIComponent(created.id)}`)
44 } catch (e) {47 } catch (e) {
45 const err = e as { status?: number, message: string }48 const err = e as { status?: number, message: string }
46- submitError.value = err.status === 40949+ submitError.value = err.status === 409 ? 'This project was already added.' : err.message
47- ? 'This project was already added.'
48- : err.status === 400
49- ? `Not a valid folder: ${err.message}`
50- : err.message
51 } finally {50 } finally {
52 submitting.value = false51 submitting.value = false
53 }52 }
@@ -114,26 +113,32 @@ watch(open, (v) => {
114 <span class="text-muted">..</span>113 <span class="text-muted">..</span>
115 </button>114 </button>
116 </li>115 </li>
117- <li v-for="e in listing.entries" :key="e.path">116+ <li
117+ v-for="e in listing.entries"
118+ :key="e.path"
119+ class="flex items-center hover:bg-elevated/60"
120+ :class="highlighted === e.path ? 'bg-primary/10' : ''"
121+ >
118 <button122 <button
119 type="button"123 type="button"
120- class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-elevated/60"124+ class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
121- :class="highlighted === e.path ? 'bg-elevated' : ''"125+ :title="e.path"
122 @click="highlight(e.path)"126 @click="highlight(e.path)"
123 @dblclick="browse(e.path)"127 @dblclick="browse(e.path)"
124 >128 >
125- <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-muted" />129+ <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" />
126 <span class="flex-1 truncate">{{ e.name }}</span>130 <span class="flex-1 truncate">{{ e.name }}</span>
127- <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" />131+ <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />
128- <UButton
129- icon="i-lucide-chevron-right"
130- size="xs"
131- color="neutral"
132- variant="ghost"
133- aria-label="Open folder"
134- @click.stop="browse(e.path)"
135- />
136 </button>132 </button>
133+ <UButton
134+ icon="i-lucide-chevron-right"
135+ size="xs"
136+ color="neutral"
137+ variant="ghost"
138+ class="mx-2 shrink-0"
139+ :aria-label="`Open ${e.name}`"
140+ @click="browse(e.path)"
141+ />
137 </li>142 </li>
138 <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>143 <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
139 </ul>144 </ul>
@@ -143,15 +148,16 @@ watch(open, (v) => {
143 148
144 <template #footer>149 <template #footer>
145 <div class="flex w-full items-center justify-between gap-2">150 <div class="flex w-full items-center justify-between gap-2">
146- <span class="truncate font-mono text-xs text-muted">{{ listing?.path ?? 'Select a drive or folder' }}</span>151+ <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span>
152+ <span v-else class="truncate text-xs text-muted">Select a drive or folder</span>
147 <div class="flex shrink-0 gap-2">153 <div class="flex shrink-0 gap-2">
148 <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />154 <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
149 <UButton155 <UButton
150 label="Select this folder"156 label="Select this folder"
151 icon="i-lucide-check"157 icon="i-lucide-check"
152- :disabled="!listing?.path"158+ :disabled="!selectedPath"
153 :loading="submitting"159 :loading="submitting"
154- @click="submit(listing!.path!)"160+ @click="submit(selectedPath!)"
155 />161 />
156 </div>162 </div>
157 </div>163 </div>
modified packages/ui/app/components/AppSidebar.vue +1 -1
@@ -27,7 +27,7 @@ const soon = [
2727
2828 <div class="mt-4 flex items-center justify-between px-2">
2929 <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span>
30- <UTooltip text="Add project">
30+ <UTooltip text="Add project" :content="{ side: 'right' }">
3131 <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" />
3232 </UTooltip>
3333 </div>
@@ -27,7 +27,7 @@ const soon = [
27 27
28 <div class="mt-4 flex items-center justify-between px-2">28 <div class="mt-4 flex items-center justify-between px-2">
29 <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span>29 <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span>
30- <UTooltip text="Add project">30+ <UTooltip text="Add project" :content="{ side: 'right' }">
31 <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" />31 <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" />
32 </UTooltip>32 </UTooltip>
33 </div>33 </div>
modified packages/ui/app/components/ItemDetail.vue +9 -3
@@ -12,12 +12,18 @@ const filePath = computed<string | null>(() => {
1212 return null
1313 })
1414
15+/** Detail rows whose value is a path, command or identifier (shown in monospace). */
16+const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to'])
17+
1518 /** Skill file currently displayed (defaults to SKILL.md). */
1619 const currentFile = ref<string | null>(null)
1720 watch(() => props.item?.id, () => { currentFile.value = null })
1821 const shownPath = computed(() => currentFile.value ?? filePath.value)
1922
20-const skillFiles = computed(() => (props.item?.type === 'skill' ? (props.item.details as SkillDetails).files : []))
23+// SKILL.md first: it is the file shown by default.
24+const skillFiles = computed(() => (props.item?.type === 'skill'
25+ ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
26+ : []))
2127
2228 const frontmatter = computed(() => {
2329 const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined
@@ -68,7 +74,7 @@ const rows = computed(() => {
6874 </script>
6975
7076 <template>
71- <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'max-w-2xl' }">
77+ <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
7278 <template #body>
7379 <div v-if="item" class="space-y-5">
7480 <UAlert
@@ -90,7 +96,7 @@ const rows = computed(() => {
9096 <tbody class="divide-y divide-default">
9197 <tr v-for="[k, v] in rows" :key="k">
9298 <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th>
93- <td class="px-3 py-2 font-mono text-xs break-all">{{ v }}</td>
99+ <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td>
94100 </tr>
95101 </tbody>
96102 </table>
@@ -12,12 +12,18 @@ const filePath = computed<string | null>(() => {
12 return null12 return null
13 })13 })
14 14
15+/** Detail rows whose value is a path, command or identifier (shown in monospace). */
16+const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to'])
17+
15 /** Skill file currently displayed (defaults to SKILL.md). */18 /** Skill file currently displayed (defaults to SKILL.md). */
16 const currentFile = ref<string | null>(null)19 const currentFile = ref<string | null>(null)
17 watch(() => props.item?.id, () => { currentFile.value = null })20 watch(() => props.item?.id, () => { currentFile.value = null })
18 const shownPath = computed(() => currentFile.value ?? filePath.value)21 const shownPath = computed(() => currentFile.value ?? filePath.value)
19 22
20-const skillFiles = computed(() => (props.item?.type === 'skill' ? (props.item.details as SkillDetails).files : []))23+// SKILL.md first: it is the file shown by default.
24+const skillFiles = computed(() => (props.item?.type === 'skill'
25+ ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
26+ : []))
21 27
22 const frontmatter = computed(() => {28 const frontmatter = computed(() => {
23 const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined29 const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined
@@ -68,7 +74,7 @@ const rows = computed(() => {
68 </script>74 </script>
69 75
70 <template>76 <template>
71- <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'max-w-2xl' }">77+ <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
72 <template #body>78 <template #body>
73 <div v-if="item" class="space-y-5">79 <div v-if="item" class="space-y-5">
74 <UAlert80 <UAlert
@@ -90,7 +96,7 @@ const rows = computed(() => {
90 <tbody class="divide-y divide-default">96 <tbody class="divide-y divide-default">
91 <tr v-for="[k, v] in rows" :key="k">97 <tr v-for="[k, v] in rows" :key="k">
92 <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th>98 <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th>
93- <td class="px-3 py-2 font-mono text-xs break-all">{{ v }}</td>99+ <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td>
94 </tr>100 </tr>
95 </tbody>101 </tbody>
96 </table>102 </table>
modified packages/ui/app/components/ItemTable.vue +5 -4
@@ -21,7 +21,7 @@ const VISIBILITY = {
2121 } as const
2222
2323 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
24-const TD = 'px-3 py-2.5 align-top'
24+const TD = 'px-3 py-2 align-middle'
2525 const MAX_ENV = 4
2626 </script>
2727
@@ -78,7 +78,7 @@ const MAX_ENV = 4
7878 <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
7979 </UTooltip>
8080 </div>
81- <p v-if="item.description && type !== 'hook'" class="line-clamp-2 text-xs text-muted">{{ item.description }}</p>
81+ <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
8282 </div>
8383 </div>
8484 </td>
@@ -97,8 +97,9 @@ const MAX_ENV = 4
9797 <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget">
9898 <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" />
9999 </UTooltip>
100- <span v-if="item.source.symlinkTarget" class="max-w-48 truncate font-mono text-xs text-muted" :title="item.source.symlinkTarget">
101- {{ item.source.symlinkTarget }}
100+ <!-- rtl keeps the end of the path (the meaningful part) visible when truncated -->
101+ <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget">
102+ <bdi>{{ item.source.symlinkTarget }}</bdi>
102103 </span>
103104 <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span>
104105 </div>
@@ -21,7 +21,7 @@ const VISIBILITY = {
21 } as const21 } as const
22 22
23 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'23 const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
24-const TD = 'px-3 py-2.5 align-top'24+const TD = 'px-3 py-2 align-middle'
25 const MAX_ENV = 425 const MAX_ENV = 4
26 </script>26 </script>
27 27
@@ -78,7 +78,7 @@ const MAX_ENV = 4
78 <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />78 <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
79 </UTooltip>79 </UTooltip>
80 </div>80 </div>
81- <p v-if="item.description && type !== 'hook'" class="line-clamp-2 text-xs text-muted">{{ item.description }}</p>81+ <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
82 </div>82 </div>
83 </div>83 </div>
84 </td>84 </td>
@@ -97,8 +97,9 @@ const MAX_ENV = 4
97 <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget">97 <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget">
98 <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" />98 <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" />
99 </UTooltip>99 </UTooltip>
100- <span v-if="item.source.symlinkTarget" class="max-w-48 truncate font-mono text-xs text-muted" :title="item.source.symlinkTarget">100+ <!-- rtl keeps the end of the path (the meaningful part) visible when truncated -->
101- {{ item.source.symlinkTarget }}101+ <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget">
102+ <bdi>{{ item.source.symlinkTarget }}</bdi>
102 </span>103 </span>
103 <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span>104 <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span>
104 </div>105 </div>
modified packages/ui/app/components/MarkdownView.vue +3 -3
@@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true })
4343 <template>
4444 <div class="space-y-3">
4545 <div class="flex items-center justify-between gap-2">
46- <span class="truncate font-mono text-xs text-muted" :title="path">{{ path }}</span>
46+ <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span>
4747 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />
4848 </div>
4949
@@ -63,14 +63,14 @@ watch(() => props.path, load, { immediate: true })
6363 :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`"
6464 />
6565
66- <pre v-if="raw || !isMarkdown" class="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>
66+ <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>
6767 <template v-else>
6868 <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default">
6969 <table class="w-full text-xs">
7070 <tbody class="divide-y divide-default">
7171 <tr v-for="[k, v] in fmRows" :key="k">
7272 <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
73- <td class="px-2 py-1.5 font-mono break-words">{{ v }}</td>
73+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
7474 </tr>
7575 </tbody>
7676 </table>
@@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true })
43 <template>43 <template>
44 <div class="space-y-3">44 <div class="space-y-3">
45 <div class="flex items-center justify-between gap-2">45 <div class="flex items-center justify-between gap-2">
46- <span class="truncate font-mono text-xs text-muted" :title="path">{{ path }}</span>46+ <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span>
47 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />47 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />
48 </div>48 </div>
49 49
@@ -63,14 +63,14 @@ watch(() => props.path, load, { immediate: true })
63 :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`"63 :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`"
64 />64 />
65 65
66- <pre v-if="raw || !isMarkdown" class="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>66+ <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>
67 <template v-else>67 <template v-else>
68 <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default">68 <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default">
69 <table class="w-full text-xs">69 <table class="w-full text-xs">
70 <tbody class="divide-y divide-default">70 <tbody class="divide-y divide-default">
71 <tr v-for="[k, v] in fmRows" :key="k">71 <tr v-for="[k, v] in fmRows" :key="k">
72 <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>72 <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
73- <td class="px-2 py-1.5 font-mono break-words">{{ v }}</td>73+ <td class="px-2 py-1.5 break-words">{{ v }}</td>
74 </tr>74 </tr>
75 </tbody>75 </tbody>
76 </table>76 </table>
modified packages/ui/app/components/ScopeView.vue +26 -3
@@ -4,7 +4,7 @@ import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/c
44 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
55
66 const { request } = useApi()
7-const { projects, remove } = useProjects()
7+const { projects, remove, refresh: refreshProjects } = useProjects()
88 const toast = useToast()
99 const router = useRouter()
1010
@@ -34,6 +34,8 @@ async function load(showLoading = true) {
3434 error.value = null
3535 } catch (e) {
3636 error.value = (e as Error).message
37+ // The folder may have disappeared: refresh the summaries so `exists` is up to date.
38+ if (props.kind === 'project') await refreshProjects()
3739 } finally {
3840 loading.value = false
3941 }
@@ -67,6 +69,10 @@ const filtered = computed(() => {
6769 const byType = computed(() => {
6870 const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
6971 for (const i of filtered.value) m[i.type].push(i)
72+ // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
73+ for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
74+ m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
75+ }
7076 return m
7177 })
7278
@@ -116,7 +122,11 @@ async function confirmRemove() {
116122 <template>
117123 <UDashboardPanel :id="`scope-${kind}`">
118124 <template #header>
119- <UDashboardNavbar :title="title" :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">
125+ <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">
126+ <template #title>
127+ <!-- The default title is a flex box, which cannot show an ellipsis for long project names -->
128+ <span class="truncate" :title="title">{{ title }}</span>
129+ </template>
120130 <template #right>
121131 <UButton
122132 icon="i-lucide-refresh-cw"
@@ -149,6 +159,19 @@ async function confirmRemove() {
149159 <!-- Error -->
150160 <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12">
151161 <UAlert
162+ v-if="folderMissing"
163+ color="warning"
164+ variant="subtle"
165+ icon="i-lucide-folder-x"
166+ title="This folder no longer exists on disk"
167+ :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`"
168+ :actions="[
169+ { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() },
170+ { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } }
171+ ]"
172+ />
173+ <UAlert
174+ v-else
152175 color="error"
153176 variant="subtle"
154177 icon="i-lucide-circle-alert"
@@ -205,7 +228,7 @@ async function confirmRemove() {
205228 <ul class="mt-2 space-y-1.5 text-xs">
206229 <li v-for="(e, i) in inventory.errors" :key="i">
207230 <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" />
208- <span class="font-mono">{{ e.path }}</span>
231+ <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span>
209232 <span class="block text-muted">{{ e.message }}</span>
210233 </li>
211234 </ul>
@@ -4,7 +4,7 @@ import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/c
4 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()4 const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
5 5
6 const { request } = useApi()6 const { request } = useApi()
7-const { projects, remove } = useProjects()7+const { projects, remove, refresh: refreshProjects } = useProjects()
8 const toast = useToast()8 const toast = useToast()
9 const router = useRouter()9 const router = useRouter()
10 10
@@ -34,6 +34,8 @@ async function load(showLoading = true) {
34 error.value = null34 error.value = null
35 } catch (e) {35 } catch (e) {
36 error.value = (e as Error).message36 error.value = (e as Error).message
37+ // The folder may have disappeared: refresh the summaries so `exists` is up to date.
38+ if (props.kind === 'project') await refreshProjects()
37 } finally {39 } finally {
38 loading.value = false40 loading.value = false
39 }41 }
@@ -67,6 +69,10 @@ const filtered = computed(() => {
67 const byType = computed(() => {69 const byType = computed(() => {
68 const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>70 const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
69 for (const i of filtered.value) m[i.type].push(i)71 for (const i of filtered.value) m[i.type].push(i)
72+ // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
73+ for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
74+ m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
75+ }
70 return m76 return m
71 })77 })
72 78
@@ -116,7 +122,11 @@ async function confirmRemove() {
116 <template>122 <template>
117 <UDashboardPanel :id="`scope-${kind}`">123 <UDashboardPanel :id="`scope-${kind}`">
118 <template #header>124 <template #header>
119- <UDashboardNavbar :title="title" :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">125+ <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">
126+ <template #title>
127+ <!-- The default title is a flex box, which cannot show an ellipsis for long project names -->
128+ <span class="truncate" :title="title">{{ title }}</span>
129+ </template>
120 <template #right>130 <template #right>
121 <UButton131 <UButton
122 icon="i-lucide-refresh-cw"132 icon="i-lucide-refresh-cw"
@@ -149,6 +159,19 @@ async function confirmRemove() {
149 <!-- Error -->159 <!-- Error -->
150 <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12">160 <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12">
151 <UAlert161 <UAlert
162+ v-if="folderMissing"
163+ color="warning"
164+ variant="subtle"
165+ icon="i-lucide-folder-x"
166+ title="This folder no longer exists on disk"
167+ :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`"
168+ :actions="[
169+ { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() },
170+ { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } }
171+ ]"
172+ />
173+ <UAlert
174+ v-else
152 color="error"175 color="error"
153 variant="subtle"176 variant="subtle"
154 icon="i-lucide-circle-alert"177 icon="i-lucide-circle-alert"
@@ -205,7 +228,7 @@ async function confirmRemove() {
205 <ul class="mt-2 space-y-1.5 text-xs">228 <ul class="mt-2 space-y-1.5 text-xs">
206 <li v-for="(e, i) in inventory.errors" :key="i">229 <li v-for="(e, i) in inventory.errors" :key="i">
207 <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" />230 <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" />
208- <span class="font-mono">{{ e.path }}</span>231+ <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span>
209 <span class="block text-muted">{{ e.message }}</span>232 <span class="block text-muted">{{ e.message }}</span>
210 </li>233 </li>
211 </ul>234 </ul>
modified packages/ui/app/layouts/default.vue +3 -2
@@ -6,8 +6,9 @@ onMounted(refresh)
66 </script>
77
88 <template>
9- <UDashboardGroup>
10- <UDashboardSidebar :ui="{ footer: 'border-t border-default' }">
9+ <UDashboardGroup unit="rem">
10+ <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows -->
11+ <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }">
1112 <template #header>
1213 <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1">
1314 <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted">
@@ -6,8 +6,9 @@ onMounted(refresh)
6 </script>6 </script>
7 7
8 <template>8 <template>
9- <UDashboardGroup>9+ <UDashboardGroup unit="rem">
10- <UDashboardSidebar :ui="{ footer: 'border-t border-default' }">10+ <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows -->
11+ <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }">
11 <template #header>12 <template #header>
12 <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1">13 <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1">
13 <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted">14 <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted">
modified packages/ui/app/pages/index.vue +2 -0
@@ -7,6 +7,8 @@ const addOpen = useState('skilly-add-open', () => false)
77
88 onMounted(() => {
99 if (!home.value) refreshHome()
10+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
11+ if (loaded.value) refreshProjects()
1012 })
1113
1214 function countItems(inv: ScopeInventory): Record<ItemType, number> {
@@ -7,6 +7,8 @@ const addOpen = useState('skilly-add-open', () => false)
7 7
8 onMounted(() => {8 onMounted(() => {
9 if (!home.value) refreshHome()9 if (!home.value) refreshHome()
10+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
11+ if (loaded.value) refreshProjects()
10 })12 })
11 13
12 function countItems(inv: ScopeInventory): Record<ItemType, number> {14 function countItems(inv: ScopeInventory): Record<ItemType, number> {
modified packages/ui/app/utils/format.ts +10 -0
@@ -43,3 +43,13 @@ export function pathSegments(p: string): { name: string, path: string }[] {
4343 })
4444 return out
4545 }
46+
47+/** Returns `p` relative to `root` when it lives inside it (keeps `p` unchanged otherwise). */
48+export function relativeTo(root: string, p: string): string {
49+ const norm = (s: string) => s.replace(/[\/]+$/, '')
50+ const r = norm(root)
51+ if (r && p.length > r.length && p.slice(0, r.length).toLowerCase() === r.toLowerCase() && /[\/]/.test(p[r.length]!)) {
52+ return p.slice(r.length + 1)
53+ }
54+ return p
55+}
@@ -43,3 +43,13 @@ export function pathSegments(p: string): { name: string, path: string }[] {
43 })43 })
44 return out44 return out
45 }45 }
46+
47+/** Returns `p` relative to `root` when it lives inside it (keeps `p` unchanged otherwise). */
48+export function relativeTo(root: string, p: string): string {
49+ const norm = (s: string) => s.replace(/[\/]+$/, '')
50+ const r = norm(root)
51+ if (r && p.length > r.length && p.slice(0, r.length).toLowerCase() === r.toLowerCase() && /[\/]/.test(p[r.length]!)) {
52+ return p.slice(r.length + 1)
53+ }
54+ return p
55+}
modified packages/ui/nuxt.config.ts +2 -1
@@ -13,7 +13,8 @@ export default defineNuxtConfig({
1313 icon: {
1414 // Icons come from the locally installed lucide collection (no network needed at runtime).
1515 provider: 'none',
16- clientBundle: { scan: true },
16+ // Scan .ts too: icon names also live in app/utils (e.g. TYPE_META), not only in templates.
17+ clientBundle: { scan: { globInclude: ['**/*.{vue,ts}'] } },
1718 serverBundle: 'local'
1819 },
1920 nitro: {
@@ -13,7 +13,8 @@ export default defineNuxtConfig({
13 icon: {13 icon: {
14 // Icons come from the locally installed lucide collection (no network needed at runtime).14 // Icons come from the locally installed lucide collection (no network needed at runtime).
15 provider: 'none',15 provider: 'none',
16- clientBundle: { scan: true },16+ // Scan .ts too: icon names also live in app/utils (e.g. TYPE_META), not only in templates.
17+ clientBundle: { scan: { globInclude: ['**/*.{vue,ts}'] } },
17 serverBundle: 'local'18 serverBundle: 'local'
18 },19 },
19 nitro: {20 nitro: {