claude-teammate 0.1.293 → 0.1.294

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-teammate",
3
- "version": "0.1.293",
3
+ "version": "0.1.294",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -124,6 +124,15 @@
124
124
  </span>
125
125
  <span>Usage</span>
126
126
  </NuxtLink>
127
+ <NuxtLink to="/skills" class="nav-item" :class="{ active: $route.path === '/skills' }" @click="closeSidebar">
128
+ <span class="nav-ico">
129
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
130
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
131
+ </svg>
132
+ </span>
133
+ <span>Skills</span>
134
+ <span class="nav-count violet" v-if="skillsCount !== null && skillsCount > 0">{{ skillsCount }}</span>
135
+ </NuxtLink>
127
136
  </div>
128
137
  </nav>
129
138
 
@@ -150,14 +159,18 @@
150
159
  <NuxtLink to="/logs" class="bottom-nav-item" :class="{ active: $route.path === '/logs' }"><span>&#8801;</span>Logs</NuxtLink>
151
160
  <NuxtLink to="/activity" class="bottom-nav-item" :class="{ active: $route.path === '/activity' }"><span>&#9673;</span>Activity</NuxtLink>
152
161
  <NuxtLink to="/usage" class="bottom-nav-item" :class="{ active: $route.path === '/usage' }"><span>&#36;</span>Usage</NuxtLink>
162
+ <NuxtLink to="/skills" class="bottom-nav-item" :class="{ active: $route.path === '/skills' }"><span>&#9881;</span>Skills</NuxtLink>
153
163
  </nav>
154
164
  </template>
155
165
 
156
166
  <script setup lang="ts">
157
167
  const { status, startPolling } = useStatus();
168
+ const { skillFixStats, loadSkillFixStats, startPolling: startSkillPolling } = useSkillFixes();
158
169
  const sidebarOpen = ref(false);
159
170
  const clockText = ref("");
160
171
 
172
+ const _skillsCount = computed(() => skillFixStats.value?.totalEvents24h ?? null);
173
+
161
174
  function _toggleSidebar() {
162
175
  sidebarOpen.value = !sidebarOpen.value;
163
176
  }
@@ -195,6 +208,8 @@ const _awaitingCount = computed(() => {
195
208
 
196
209
  onMounted(() => {
197
210
  startPolling();
211
+ startSkillPolling(15000);
212
+ loadSkillFixStats();
198
213
  clockText.value = new Date().toLocaleTimeString("en-US", { hour12: false });
199
214
  setInterval(() => {
200
215
  clockText.value = new Date().toLocaleTimeString("en-US", { hour12: false });
@@ -65,11 +65,10 @@
65
65
  <div class="stat-sub">Queued for retry</div>
66
66
  <div class="stat-progress"><div class="stat-progress-fill" :style="{ width: stuckBarPct + '%', background: 'var(--red)' }"></div></div>
67
67
  </div>
68
- <a
68
+ <NuxtLink
69
69
  class="stat-card stat-card-link"
70
70
  v-if="skillFixStats && skillFixStats.totalEvents24h > 0"
71
- href="#skill-self-repair"
72
- @click.prevent="scrollToSkillCard"
71
+ to="/skills"
73
72
  >
74
73
  <div class="stat-card-accent" style="background:linear-gradient(90deg,var(--violet),var(--sky))"></div>
75
74
  <div class="stat-label">Skills</div>
@@ -80,7 +79,7 @@
80
79
  <div class="stat-progress">
81
80
  <div class="stat-progress-fill" :style="{ width: skillSuccessPct + '%', background: 'var(--violet)' }"></div>
82
81
  </div>
83
- </a>
82
+ </NuxtLink>
84
83
  </div>
85
84
 
86
85
  <!-- STUCK TASKS TABLE -->
@@ -292,111 +291,6 @@
292
291
  </div>
293
292
  </template>
294
293
 
295
- <!-- SKILL SELF-REPAIR -->
296
- <div class="card" v-if="skillFixes.length > 0" id="skill-self-repair" ref="skillCardRef">
297
- <div class="card-header">
298
- <span class="card-title">⚙ Skill Self-Repair</span>
299
- <span v-if="skillFixStats" style="font-family:var(--f-mono);font-size:.7rem;color:var(--ink-3)">
300
- {{ skillFixStats.totalEvents24h }} events 24h
301
- <span v-if="skillSuccessLabel"> • {{ skillSuccessLabel }}</span>
302
- <span v-if="skillFixStats.cooldownActive.length > 0"> • {{ skillFixStats.cooldownActive.length }} cooldown</span>
303
- </span>
304
- </div>
305
- <!-- Active fixes banner -->
306
- <div v-if="activeSkillFixes.length > 0" style="padding:8px 12px;background:rgba(234,179,8,.08);border-bottom:1px solid rgba(234,179,8,.2);font-size:.8rem;color:var(--ink-2)">
307
- <span style="color:#ca8a04;font-weight:600">Fixing:</span>
308
- <span v-for="f in activeSkillFixes" :key="f.skill + f.ts" style="margin-left:8px">
309
- <span style="font-family:var(--f-mono);color:var(--ink-1);cursor:pointer;text-decoration:underline" @click="openSkillDrawer(f.skill)">{{ f.skill }}</span>
310
- <span style="color:var(--ink-3);margin-left:4px">({{ f.location || '?' }})</span>
311
- </span>
312
- </div>
313
-
314
- <!-- Top failing skills -->
315
- <div v-if="skillFixStats && skillFixStats.topFailingSkills.length > 0" style="padding:10px 12px;border-bottom:1px solid var(--border-color)">
316
- <div style="font-size:.65rem;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-3);margin-bottom:6px">Top problem skills (24h)</div>
317
- <div style="display:flex;flex-direction:column;gap:4px">
318
- <div v-for="s in skillFixStats.topFailingSkills.slice(0, 5)" :key="s.skill" class="topfail-row">
319
- <span class="topfail-name" @click="openSkillDrawer(s.skill)">{{ s.skill }}</span>
320
- <div class="topfail-bar"><div class="topfail-bar-fill" :style="{ width: topFailBarPct(s.errorCount) + '%' }"></div></div>
321
- <span class="topfail-count">{{ s.errorCount }} err</span>
322
- <span v-if="s.fixCount > 0" class="topfail-fixes">{{ s.fixCount }} ✓</span>
323
- </div>
324
- </div>
325
- </div>
326
-
327
- <!-- Improvement opportunities -->
328
- <div v-if="improvementEvents.length > 0" style="padding:10px 12px;border-bottom:1px solid var(--border-color)">
329
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
330
- <div style="font-size:.65rem;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-3)">
331
- Improvement opportunities ({{ improvementEvents.length }})
332
- </div>
333
- <label style="font-size:.7rem;color:var(--ink-3);cursor:pointer">
334
- <input type="checkbox" v-model="hideImprovements" /> hide
335
- </label>
336
- </div>
337
- <div v-if="!hideImprovements" style="display:flex;flex-direction:column;gap:4px">
338
- <div v-for="ev in improvementEvents.slice(0, 5)" :key="ev.ts + ev.skill" style="display:flex;align-items:center;gap:8px;font-size:.78rem">
339
- <span style="font-family:var(--f-mono);font-weight:600;cursor:pointer;text-decoration:underline" @click="openSkillDrawer(ev.skill)">{{ ev.skill }}</span>
340
- <span style="color:var(--ink-3);font-size:.72rem">{{ skillFixTriggerLabel(ev.errorType) }}</span>
341
- <a v-if="ev.prUrl" :href="ev.prUrl" target="_blank" rel="noreferrer" style="margin-left:auto;color:var(--sky);font-size:.72rem">PR →</a>
342
- </div>
343
- </div>
344
- </div>
345
-
346
- <div class="table-wrap">
347
- <table>
348
- <thead>
349
- <tr>
350
- <th>Time</th>
351
- <th>Skill</th>
352
- <th>Location</th>
353
- <th>Trigger</th>
354
- <th>Mode</th>
355
- <th>Status</th>
356
- <th>Strategy</th>
357
- <th>Cooldown</th>
358
- <th>Details</th>
359
- </tr>
360
- </thead>
361
- <tbody>
362
- <tr v-for="ev in skillFixes.slice(0, 20)" :key="ev.skill + ev.ts" class="clickable" @click="openSkillDrawer(ev.skill)" title="View skill details">
363
- <td style="font-family:var(--f-mono);font-size:.75rem;color:var(--ink-3);white-space:nowrap">{{ formatRelative(ev.ts) }}</td>
364
- <td style="font-family:var(--f-mono);font-weight:600">{{ ev.skill }}</td>
365
- <td>
366
- <span v-if="ev.location" :class="['tag', ev.location === 'repo' ? 'sky' : 'violet']">{{ ev.location }}</span>
367
- <span v-else style="color:var(--ink-3)">—</span>
368
- </td>
369
- <td style="font-size:.75rem;color:var(--ink-2)">{{ skillFixTriggerLabel(ev.errorType) }}</td>
370
- <td>
371
- <span v-if="ev.mode" :class="['tag', ev.mode === 'improve' ? 'sky' : 'red']" style="font-size:.65rem">{{ ev.mode }}</span>
372
- <span v-else style="color:var(--ink-3)">—</span>
373
- </td>
374
- <td>
375
- <span :class="['tag', skillFixStatusClass(ev.status)]">{{ ev.status }}</span>
376
- </td>
377
- <td>
378
- <span v-if="ev.strategy" class="tag" style="font-size:.65rem">{{ ev.strategy }}</span>
379
- <span v-else style="color:var(--ink-3)">—</span>
380
- </td>
381
- <td>
382
- <span
383
- v-if="typeof ev.recentFixCount === 'number'"
384
- :style="{ fontFamily: 'var(--f-mono)', fontSize: '.72rem', color: ev.recentFixCount >= 2 ? 'var(--red)' : 'var(--ink-3)' }"
385
- >{{ ev.recentFixCount }}</span>
386
- <span v-else style="color:var(--ink-3)">—</span>
387
- </td>
388
- <td style="font-size:.75rem" @click.stop>
389
- <a v-if="ev.prUrl" :href="ev.prUrl" target="_blank" style="color:var(--sky);text-decoration:none">PR →</a>
390
- <span v-else-if="ev.error" style="color:var(--red);font-family:var(--f-mono)" :title="ev.error">{{ ev.error.slice(0, 40) }}…</span>
391
- <span v-else-if="ev.files" style="color:var(--ink-3)">{{ ev.files }} file{{ ev.files !== 1 ? 's' : '' }}</span>
392
- <span v-else style="color:var(--ink-3)">—</span>
393
- </td>
394
- </tr>
395
- </tbody>
396
- </table>
397
- </div>
398
- </div>
399
-
400
294
  <!-- ISSUE DRAWER (always mounted) -->
401
295
  <IssueDrawer
402
296
  :issue="drawerIssue"
@@ -405,28 +299,14 @@
405
299
  @close="closeDrawer"
406
300
  @reset-done="refresh"
407
301
  />
408
-
409
- <!-- SKILL FIX DRAWER -->
410
- <SkillFixDrawer
411
- :skill="skillDrawerSkill"
412
- :open="skillDrawerOpen"
413
- @close="closeSkillDrawer"
414
- @rolled="refresh"
415
- />
416
302
  </div>
417
303
  </template>
418
304
 
419
305
  <script setup lang="ts">
420
- import type { DraftPr, ReviewPr, SkillFixEvent, StuckTask, WorkflowIssue } from "~/composables/useStatus";
306
+ import type { DraftPr, ReviewPr, StuckTask, WorkflowIssue } from "~/composables/useStatus";
421
307
 
422
308
  const { status, loadStatus } = useStatus();
423
- const {
424
- skillFixes,
425
- skillFixStats,
426
- loadSkillFixes,
427
- loadSkillFixStats,
428
- startPolling: startSkillPolling
429
- } = useSkillFixes();
309
+ const { skillFixStats, loadSkillFixes, loadSkillFixStats, startPolling: startSkillPolling } = useSkillFixes();
430
310
  const { apiFetch } = useApi();
431
311
  // biome-ignore lint/correctness/noUnusedVariables: used in template
432
312
  const { formatTime, formatRelative, formatDuration, stateLabel, shortenUrl } = useHelpers();
@@ -610,27 +490,6 @@ function closeDrawer() {
610
490
  drawerOpen.value = false;
611
491
  }
612
492
 
613
- // Stale-guard: a worker crash mid-generation leaves a "generating" event in the
614
- // log forever. Treat events older than 5 minutes as stale so the banner doesn't
615
- // show ghost fixes.
616
- const ACTIVE_FIX_STALE_MS = 5 * 60 * 1000;
617
- // biome-ignore lint/correctness/noUnusedVariables: used in template
618
- const activeSkillFixes = computed(() =>
619
- (skillFixes.value as SkillFixEvent[]).filter(
620
- (e) => e.status === "generating" && Date.now() - new Date(e.ts).getTime() < ACTIVE_FIX_STALE_MS
621
- )
622
- );
623
-
624
- // biome-ignore lint/correctness/noUnusedVariables: used in template
625
- const improvementEvents = computed(() =>
626
- (skillFixes.value as SkillFixEvent[]).filter(
627
- (e) => e.mode === "improve" && e.errorType === "skill-improvement-opportunity"
628
- )
629
- );
630
-
631
- // biome-ignore lint/correctness/noUnusedVariables: used in template
632
- const hideImprovements = ref(false);
633
-
634
493
  // biome-ignore lint/correctness/noUnusedVariables: used in template
635
494
  const skillSuccessLabel = computed(() => {
636
495
  const r = skillFixStats.value?.successRate24h;
@@ -645,58 +504,6 @@ const skillSuccessPct = computed(() => {
645
504
  return Math.round(r * 100);
646
505
  });
647
506
 
648
- // biome-ignore lint/correctness/noUnusedVariables: used in template
649
- function topFailBarPct(count: number): number {
650
- const max = skillFixStats.value?.topFailingSkills[0]?.errorCount || 1;
651
- return Math.min(100, (count / Math.max(max, 1)) * 100);
652
- }
653
-
654
- // biome-ignore lint/correctness/noUnusedVariables: used in template
655
- function skillFixStatusClass(status: SkillFixEvent["status"]): string {
656
- if (status === "pr-created" || status === "patched" || status === "patched-with-backup") return "green";
657
- if (status === "generating") return "yellow";
658
- if (status === "patch-failed") return "orange";
659
- if (status === "cooldown") return "violet";
660
- if (status === "error" || status === "no-fix" || status === "generation-error") return "red";
661
- if (status === "no-op" || status === "pr-exists") return "sky";
662
- if (status === "lock-skipped" || status === "not-found" || status === "scope-excluded") return "violet";
663
- return "";
664
- }
665
-
666
- // biome-ignore lint/correctness/noUnusedVariables: used in template
667
- function skillFixTriggerLabel(errorType?: string): string {
668
- if (!errorType) return "—";
669
- if (errorType === "user-feedback") return "user feedback";
670
- if (errorType === "bash-error-in-skill") return "bash error";
671
- if (errorType === "tool-error-in-skill") return "mcp error";
672
- if (errorType === "skill-load-failed") return "load failed";
673
- if (errorType === "skill-bypassed") return "bypassed";
674
- if (errorType === "skill-caused-retry") return "silent retry";
675
- if (errorType === "skill-caused-fallback") return "silent fallback";
676
- if (errorType === "skill-improvement-opportunity") return "improve";
677
- return errorType;
678
- }
679
-
680
- const skillCardRef = ref<HTMLElement | null>(null);
681
- const skillDrawerOpen = ref(false);
682
- const skillDrawerSkill = ref<string | null>(null);
683
-
684
- // biome-ignore lint/correctness/noUnusedVariables: used in template
685
- function openSkillDrawer(skill: string) {
686
- skillDrawerSkill.value = skill;
687
- skillDrawerOpen.value = true;
688
- }
689
-
690
- // biome-ignore lint/correctness/noUnusedVariables: used in template
691
- function closeSkillDrawer() {
692
- skillDrawerOpen.value = false;
693
- }
694
-
695
- // biome-ignore lint/correctness/noUnusedVariables: used in template
696
- function scrollToSkillCard() {
697
- skillCardRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
698
- }
699
-
700
507
  // biome-ignore lint/correctness/noUnusedVariables: used in template
701
508
  async function refresh() {
702
509
  await Promise.all([loadStatus(), loadSkillFixes(), loadSkillFixStats()]);
@@ -797,48 +604,4 @@ onMounted(async () => {
797
604
  transition: transform .12s ease;
798
605
  }
799
606
  .stat-card-link:hover { transform: translateY(-1px); }
800
-
801
- .topfail-row {
802
- display: flex;
803
- align-items: center;
804
- gap: 8px;
805
- font-size: .78rem;
806
- }
807
- .topfail-name {
808
- font-family: var(--f-mono);
809
- font-weight: 600;
810
- color: var(--ink-1);
811
- cursor: pointer;
812
- text-decoration: underline;
813
- min-width: 100px;
814
- max-width: 220px;
815
- overflow: hidden;
816
- text-overflow: ellipsis;
817
- white-space: nowrap;
818
- }
819
- .topfail-bar {
820
- flex: 1;
821
- height: 6px;
822
- background: var(--bg-input);
823
- border-radius: 3px;
824
- overflow: hidden;
825
- min-width: 60px;
826
- }
827
- .topfail-bar-fill {
828
- height: 100%;
829
- background: var(--red);
830
- transition: width .2s;
831
- }
832
- .topfail-count {
833
- font-family: var(--f-mono);
834
- font-size: .72rem;
835
- color: var(--red);
836
- min-width: 50px;
837
- text-align: right;
838
- }
839
- .topfail-fixes {
840
- font-family: var(--f-mono);
841
- font-size: .72rem;
842
- color: var(--green);
843
- }
844
607
  </style>
@@ -0,0 +1,482 @@
1
+ <template>
2
+ <div class="page-in">
3
+ <div class="page-header">
4
+ <div>
5
+ <div class="page-title">Skill Self-Repair</div>
6
+ <div class="page-sub">{{ subLine }}</div>
7
+ </div>
8
+ <button class="btn" @click="refresh">↻ Refresh</button>
9
+ </div>
10
+
11
+ <template v-if="skillFixes.length === 0 && !loading">
12
+ <div class="card" style="padding:24px;text-align:center;color:var(--ink-3)">
13
+ No skill self-repair events recorded.
14
+ </div>
15
+ </template>
16
+
17
+ <template v-else>
18
+ <!-- STAT GRID -->
19
+ <div class="stat-grid">
20
+ <div class="stat-card">
21
+ <div class="stat-card-accent" style="background:linear-gradient(90deg,var(--violet),var(--sky))"></div>
22
+ <div class="stat-label">Events 24h</div>
23
+ <div class="stat-value">{{ skillFixStats?.totalEvents24h ?? 0 }}</div>
24
+ <div class="stat-sub">{{ skillFixStats?.totalEvents7d ?? 0 }} in 7d</div>
25
+ </div>
26
+ <div class="stat-card">
27
+ <div class="stat-card-accent green"></div>
28
+ <div class="stat-label">Success Rate</div>
29
+ <div class="stat-value">{{ skillSuccessLabel || '—' }}</div>
30
+ <div class="stat-progress"><div class="stat-progress-fill" :style="{ width: skillSuccessPct + '%', background: 'var(--green)' }"></div></div>
31
+ </div>
32
+ <div class="stat-card">
33
+ <div class="stat-card-accent violet"></div>
34
+ <div class="stat-label">Cooldown</div>
35
+ <div class="stat-value">{{ skillFixStats?.cooldownActive.length ?? 0 }}</div>
36
+ <div class="stat-sub">{{ skillFixStats?.cooldownActive.length ? 'paused skills' : 'none' }}</div>
37
+ </div>
38
+ <div class="stat-card">
39
+ <div class="stat-card-accent sky"></div>
40
+ <div class="stat-label">Improvements</div>
41
+ <div class="stat-value">{{ skillFixStats?.improvementOpportunities24h ?? 0 }}</div>
42
+ <div class="stat-sub">opportunities 24h</div>
43
+ </div>
44
+ <div class="stat-card">
45
+ <div class="stat-card-accent orange"></div>
46
+ <div class="stat-label">Active Fixes</div>
47
+ <div class="stat-value">{{ activeSkillFixes.length }}</div>
48
+ <div class="stat-sub">in progress</div>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- ACTIVE FIXES BANNER -->
53
+ <div class="card" v-if="activeSkillFixes.length > 0" style="padding:10px 14px;background:rgba(234,179,8,.06);border-color:rgba(234,179,8,.3)">
54
+ <span style="color:#ca8a04;font-weight:600;font-size:.78rem">Currently fixing:</span>
55
+ <span v-for="f in activeSkillFixes" :key="f.skill + f.ts" style="margin-left:10px;font-size:.78rem">
56
+ <span style="font-family:var(--f-mono);cursor:pointer;text-decoration:underline" @click="openSkillDrawer(f.skill)">{{ f.skill }}</span>
57
+ <span style="color:var(--ink-3);margin-left:4px">({{ f.location || '?' }})</span>
58
+ </span>
59
+ </div>
60
+
61
+ <!-- TOP FAILING -->
62
+ <div class="card" v-if="skillFixStats && skillFixStats.topFailingSkills.length > 0">
63
+ <div class="card-header">
64
+ <span class="card-title">Top problem skills (24h)</span>
65
+ </div>
66
+ <div style="padding:10px 14px;display:flex;flex-direction:column;gap:6px">
67
+ <div v-for="s in skillFixStats.topFailingSkills" :key="s.skill" class="topfail-row">
68
+ <span class="topfail-name" @click="openSkillDrawer(s.skill)">{{ s.skill }}</span>
69
+ <div class="topfail-bar"><div class="topfail-bar-fill" :style="{ width: topFailBarPct(s.errorCount) + '%' }"></div></div>
70
+ <span class="topfail-count">{{ s.errorCount }} err</span>
71
+ <span v-if="s.fixCount > 0" class="topfail-fixes">{{ s.fixCount }} ✓</span>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- IMPROVEMENT OPPORTUNITIES -->
77
+ <div class="card" v-if="improvementEvents.length > 0">
78
+ <div class="card-header">
79
+ <span class="card-title">Improvement opportunities</span>
80
+ <span style="font-family:var(--f-mono);font-size:.7rem;color:var(--ink-3)">{{ improvementEvents.length }} total</span>
81
+ </div>
82
+ <div style="padding:10px 14px;display:flex;flex-direction:column;gap:6px">
83
+ <div v-for="ev in improvementEvents.slice(0, 10)" :key="ev.ts + ev.skill" style="display:flex;align-items:center;gap:8px;font-size:.78rem">
84
+ <span style="font-family:var(--f-mono);font-weight:600;cursor:pointer;text-decoration:underline" @click="openSkillDrawer(ev.skill)">{{ ev.skill }}</span>
85
+ <span style="color:var(--ink-3);font-size:.72rem">{{ skillFixTriggerLabel(ev.errorType) }}</span>
86
+ <span style="color:var(--ink-3);font-size:.7rem;margin-left:auto">{{ formatRelative(ev.ts) }}</span>
87
+ <a v-if="ev.prUrl" :href="ev.prUrl" target="_blank" rel="noreferrer" style="color:var(--sky);font-size:.72rem">PR →</a>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- GROUPED TABLE -->
93
+ <div class="card">
94
+ <div class="card-header">
95
+ <span class="card-title">Skills</span>
96
+ <div style="display:flex;align-items:center;gap:10px">
97
+ <input
98
+ v-model="filterKw"
99
+ placeholder="Filter skill…"
100
+ class="fsel"
101
+ style="height:30px;font-size:.78rem;width:180px"
102
+ />
103
+ <span style="font-family:var(--f-mono);font-size:.7rem;color:var(--ink-3)">
104
+ {{ filteredGroups.length }} skill{{ filteredGroups.length !== 1 ? 's' : '' }}
105
+ </span>
106
+ </div>
107
+ </div>
108
+ <div class="table-wrap">
109
+ <table>
110
+ <thead>
111
+ <tr>
112
+ <th style="width:24px"></th>
113
+ <th><button class="sort-btn" :class="{ 'sort-active': sort.col==='skill' }" @click="toggleSort('skill')">Skill {{ sortIco('skill') }}</button></th>
114
+ <th><button class="sort-btn" :class="{ 'sort-active': sort.col==='total' }" @click="toggleSort('total')">Events {{ sortIco('total') }}</button></th>
115
+ <th><button class="sort-btn" :class="{ 'sort-active': sort.col==='errors' }" @click="toggleSort('errors')">Errors {{ sortIco('errors') }}</button></th>
116
+ <th><button class="sort-btn" :class="{ 'sort-active': sort.col==='success' }" @click="toggleSort('success')">✓ {{ sortIco('success') }}</button></th>
117
+ <th class="max-[860px]:hidden">Location</th>
118
+ <th class="max-[860px]:hidden">Last status</th>
119
+ <th class="max-[640px]:hidden"><button class="sort-btn" :class="{ 'sort-active': sort.col==='lastTs' }" @click="toggleSort('lastTs')">Last activity {{ sortIco('lastTs') }}</button></th>
120
+ <th>Last detail</th>
121
+ </tr>
122
+ </thead>
123
+ <tbody>
124
+ <template v-for="g in filteredGroups" :key="g.skill">
125
+ <tr class="clickable" @click="toggleExpand(g.skill)" :title="expanded.has(g.skill) ? 'Collapse' : 'Expand'">
126
+ <td style="text-align:center;color:var(--ink-3);font-family:var(--f-mono);font-size:.7rem">
127
+ {{ expanded.has(g.skill) ? '▾' : '▸' }}
128
+ </td>
129
+ <td style="font-family:var(--f-mono);font-weight:600">
130
+ <span @click.stop="openSkillDrawer(g.skill)" style="cursor:pointer;text-decoration:underline">{{ g.skill }}</span>
131
+ </td>
132
+ <td>{{ g.total }}</td>
133
+ <td>
134
+ <span :style="{ color: g.errors > 0 ? 'var(--red)' : 'var(--ink-3)', fontWeight: g.errors > 0 ? 600 : 400 }">{{ g.errors }}</span>
135
+ </td>
136
+ <td>
137
+ <span :style="{ color: g.success > 0 ? 'var(--green)' : 'var(--ink-3)' }">{{ g.success }}</span>
138
+ </td>
139
+ <td class="max-[860px]:hidden">
140
+ <span v-if="g.location" :class="['tag', g.location === 'repo' ? 'sky' : 'violet']" style="font-size:.65rem">{{ g.location }}</span>
141
+ <span v-else style="color:var(--ink-3)">—</span>
142
+ </td>
143
+ <td class="max-[860px]:hidden">
144
+ <span :class="['tag', skillFixStatusClass(g.lastStatus)]" style="font-size:.65rem">{{ g.lastStatus }}</span>
145
+ </td>
146
+ <td class="max-[640px]:hidden" style="font-family:var(--f-mono);font-size:.74rem;color:var(--ink-3);white-space:nowrap">{{ formatRelative(g.lastTs) }}</td>
147
+ <td style="font-size:.74rem;max-width:260px" @click.stop>
148
+ <a v-if="g.lastPrUrl" :href="g.lastPrUrl" target="_blank" rel="noreferrer" style="color:var(--sky);text-decoration:none">PR →</a>
149
+ <span v-else-if="g.lastError" style="color:var(--red);font-family:var(--f-mono);display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle" :title="g.lastError">{{ g.lastError }}</span>
150
+ <span v-else-if="g.lastFiles" style="color:var(--ink-3)">{{ g.lastFiles }} file{{ g.lastFiles !== 1 ? 's' : '' }}</span>
151
+ <span v-else style="color:var(--ink-3)">—</span>
152
+ </td>
153
+ </tr>
154
+ <!-- EXPANDED ROW -->
155
+ <tr v-if="expanded.has(g.skill)" class="expanded-row">
156
+ <td></td>
157
+ <td colspan="8" style="padding:0">
158
+ <div style="padding:10px 14px;background:var(--bg-alt);border-top:1px solid var(--border-color);border-bottom:1px solid var(--border-color)">
159
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
160
+ <span style="font-size:.68rem;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-3)">All events ({{ g.events.length }})</span>
161
+ <button class="btn" style="height:24px;padding:0 8px;font-size:.7rem;margin-left:auto" @click.stop="openSkillDrawer(g.skill)">Open drawer →</button>
162
+ </div>
163
+ <div style="display:flex;flex-direction:column;gap:6px">
164
+ <div v-for="(ev, idx) in g.events" :key="ev.ts + idx" class="ev-line">
165
+ <div class="ev-line-head">
166
+ <span style="font-family:var(--f-mono);font-size:.7rem;color:var(--ink-3);min-width:90px">{{ formatRelative(ev.ts) }}</span>
167
+ <span :class="['tag', skillFixStatusClass(ev.status)]" style="font-size:.62rem">{{ ev.status }}</span>
168
+ <span v-if="ev.mode" :class="['tag', ev.mode === 'improve' ? 'sky' : 'red']" style="font-size:.62rem">{{ ev.mode }}</span>
169
+ <span v-if="ev.strategy" class="tag" style="font-size:.62rem">{{ ev.strategy }}</span>
170
+ <span style="font-size:.7rem;color:var(--ink-2)">{{ skillFixTriggerLabel(ev.errorType) }}</span>
171
+ <span v-if="typeof ev.recentFixCount === 'number' && ev.recentFixCount > 0" style="font-family:var(--f-mono);font-size:.68rem;color:var(--ink-3);margin-left:auto">cooldown:{{ ev.recentFixCount }}</span>
172
+ <a v-if="ev.prUrl" :href="ev.prUrl" target="_blank" rel="noreferrer" style="color:var(--sky);font-size:.7rem;margin-left:auto">PR →</a>
173
+ </div>
174
+ <div v-if="ev.error" class="ev-line-error">{{ ev.error }}</div>
175
+ <div v-else-if="ev.files" style="font-size:.7rem;color:var(--ink-3);margin-top:2px">{{ ev.files }} file{{ ev.files !== 1 ? 's' : '' }} processed</div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </td>
180
+ </tr>
181
+ </template>
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ </div>
186
+ </template>
187
+
188
+ <SkillFixDrawer
189
+ :skill="skillDrawerSkill"
190
+ :open="skillDrawerOpen"
191
+ @close="closeSkillDrawer"
192
+ @rolled="refresh"
193
+ />
194
+ </div>
195
+ </template>
196
+
197
+ <script setup lang="ts">
198
+ import type { SkillFixEvent } from "~/composables/useStatus";
199
+
200
+ const {
201
+ skillFixes,
202
+ skillFixStats,
203
+ loadSkillFixes,
204
+ loadSkillFixStats,
205
+ startPolling: startSkillPolling
206
+ } = useSkillFixes();
207
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
208
+ const { formatRelative } = useHelpers();
209
+
210
+ const loading = ref(true);
211
+
212
+ const ACTIVE_FIX_STALE_MS = 5 * 60 * 1000;
213
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
214
+ const activeSkillFixes = computed(() =>
215
+ (skillFixes.value as SkillFixEvent[]).filter(
216
+ (e) => e.status === "generating" && Date.now() - new Date(e.ts).getTime() < ACTIVE_FIX_STALE_MS
217
+ )
218
+ );
219
+
220
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
221
+ const improvementEvents = computed(() =>
222
+ (skillFixes.value as SkillFixEvent[]).filter(
223
+ (e) => e.mode === "improve" && e.errorType === "skill-improvement-opportunity"
224
+ )
225
+ );
226
+
227
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
228
+ const skillSuccessLabel = computed(() => {
229
+ const r = skillFixStats.value?.successRate24h;
230
+ if (r === null || r === undefined) return "";
231
+ return `${Math.round(r * 100)}% ✓`;
232
+ });
233
+
234
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
235
+ const skillSuccessPct = computed(() => {
236
+ const r = skillFixStats.value?.successRate24h;
237
+ if (r === null || r === undefined) return 0;
238
+ return Math.round(r * 100);
239
+ });
240
+
241
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
242
+ const subLine = computed(() => {
243
+ const total = skillFixStats.value?.totalEvents24h ?? 0;
244
+ return total ? `${total} events in last 24h` : "Monitor & retry self-repair attempts";
245
+ });
246
+
247
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
248
+ function topFailBarPct(count: number): number {
249
+ const max = skillFixStats.value?.topFailingSkills[0]?.errorCount || 1;
250
+ return Math.min(100, (count / Math.max(max, 1)) * 100);
251
+ }
252
+
253
+ const ERROR_STATUSES = new Set(["error", "no-fix", "generation-error", "patch-failed"]);
254
+ const SUCCESS_STATUSES = new Set(["pr-created", "patched", "patched-with-backup"]);
255
+
256
+ interface SkillGroup {
257
+ skill: string;
258
+ total: number;
259
+ errors: number;
260
+ success: number;
261
+ location?: string;
262
+ lastTs: string;
263
+ lastStatus: SkillFixEvent["status"];
264
+ lastError?: string;
265
+ lastPrUrl?: string;
266
+ lastFiles?: number;
267
+ events: SkillFixEvent[];
268
+ }
269
+
270
+ const groups = computed<SkillGroup[]>(() => {
271
+ const map = new Map<string, SkillGroup>();
272
+ const sorted = [...(skillFixes.value as SkillFixEvent[])].sort(
273
+ (a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()
274
+ );
275
+ for (const ev of sorted) {
276
+ let g = map.get(ev.skill);
277
+ if (!g) {
278
+ g = {
279
+ skill: ev.skill,
280
+ total: 0,
281
+ errors: 0,
282
+ success: 0,
283
+ location: ev.location,
284
+ lastTs: ev.ts,
285
+ lastStatus: ev.status,
286
+ lastError: ev.error,
287
+ lastPrUrl: ev.prUrl,
288
+ lastFiles: ev.files,
289
+ events: []
290
+ };
291
+ map.set(ev.skill, g);
292
+ }
293
+ g.total++;
294
+ if (ERROR_STATUSES.has(ev.status)) g.errors++;
295
+ if (SUCCESS_STATUSES.has(ev.status)) g.success++;
296
+ g.events.push(ev);
297
+ }
298
+ return [...map.values()];
299
+ });
300
+
301
+ const filterKw = ref("");
302
+ const sort = ref<{ col: "skill" | "total" | "errors" | "success" | "lastTs"; dir: "asc" | "desc" }>({
303
+ col: "lastTs",
304
+ dir: "desc"
305
+ });
306
+
307
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
308
+ function toggleSort(col: "skill" | "total" | "errors" | "success" | "lastTs") {
309
+ sort.value =
310
+ sort.value.col === col
311
+ ? { col, dir: sort.value.dir === "desc" ? "asc" : "desc" }
312
+ : { col, dir: col === "skill" ? "asc" : "desc" };
313
+ }
314
+
315
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
316
+ function sortIco(col: string): string {
317
+ if (sort.value.col !== col) return "↕";
318
+ return sort.value.dir === "desc" ? "↓" : "↑";
319
+ }
320
+
321
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
322
+ const filteredGroups = computed(() => {
323
+ const kw = filterKw.value.trim().toLowerCase();
324
+ let list = groups.value;
325
+ if (kw) list = list.filter((g) => g.skill.toLowerCase().includes(kw));
326
+ const { col, dir } = sort.value;
327
+ return [...list].sort((a, b) => {
328
+ let av: number | string;
329
+ let bv: number | string;
330
+ if (col === "lastTs") {
331
+ av = new Date(a.lastTs).getTime();
332
+ bv = new Date(b.lastTs).getTime();
333
+ } else if (col === "skill") {
334
+ av = a.skill;
335
+ bv = b.skill;
336
+ } else {
337
+ av = a[col];
338
+ bv = b[col];
339
+ }
340
+ if (typeof av === "number" && typeof bv === "number") return dir === "asc" ? av - bv : bv - av;
341
+ return dir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
342
+ });
343
+ });
344
+
345
+ const expanded = ref(new Set<string>());
346
+
347
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
348
+ function toggleExpand(skill: string) {
349
+ const next = new Set(expanded.value);
350
+ if (next.has(skill)) next.delete(skill);
351
+ else next.add(skill);
352
+ expanded.value = next;
353
+ }
354
+
355
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
356
+ function skillFixStatusClass(status: SkillFixEvent["status"]): string {
357
+ if (status === "pr-created" || status === "patched" || status === "patched-with-backup") return "green";
358
+ if (status === "generating") return "yellow";
359
+ if (status === "patch-failed") return "orange";
360
+ if (status === "cooldown") return "violet";
361
+ if (status === "error" || status === "no-fix" || status === "generation-error") return "red";
362
+ if (status === "no-op" || status === "pr-exists") return "sky";
363
+ if (status === "lock-skipped" || status === "not-found" || status === "scope-excluded") return "violet";
364
+ return "";
365
+ }
366
+
367
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
368
+ function skillFixTriggerLabel(errorType?: string): string {
369
+ if (!errorType) return "—";
370
+ if (errorType === "user-feedback") return "user feedback";
371
+ if (errorType === "bash-error-in-skill") return "bash error";
372
+ if (errorType === "tool-error-in-skill") return "mcp error";
373
+ if (errorType === "skill-load-failed") return "load failed";
374
+ if (errorType === "skill-bypassed") return "bypassed";
375
+ if (errorType === "skill-caused-retry") return "silent retry";
376
+ if (errorType === "skill-caused-fallback") return "silent fallback";
377
+ if (errorType === "skill-improvement-opportunity") return "improve";
378
+ return errorType;
379
+ }
380
+
381
+ const skillDrawerOpen = ref(false);
382
+ const skillDrawerSkill = ref<string | null>(null);
383
+
384
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
385
+ function openSkillDrawer(skill: string) {
386
+ skillDrawerSkill.value = skill;
387
+ skillDrawerOpen.value = true;
388
+ }
389
+
390
+ // biome-ignore lint/correctness/noUnusedVariables: used in template
391
+ function closeSkillDrawer() {
392
+ skillDrawerOpen.value = false;
393
+ }
394
+
395
+ async function refresh() {
396
+ loading.value = true;
397
+ await Promise.all([loadSkillFixes(), loadSkillFixStats()]);
398
+ loading.value = false;
399
+ }
400
+
401
+ onMounted(async () => {
402
+ startSkillPolling(15000);
403
+ await refresh();
404
+ });
405
+ </script>
406
+
407
+ <style scoped>
408
+ .sort-btn {
409
+ background: none;
410
+ border: none;
411
+ color: var(--ink-3);
412
+ font-size: .72rem;
413
+ font-weight: 600;
414
+ text-transform: uppercase;
415
+ letter-spacing: .5px;
416
+ cursor: pointer;
417
+ padding: 0;
418
+ display: inline-flex;
419
+ align-items: center;
420
+ gap: 3px;
421
+ white-space: nowrap;
422
+ transition: color .15s;
423
+ }
424
+ .sort-btn:hover { color: var(--ink); }
425
+ .sort-active { color: var(--brand) !important; }
426
+
427
+ .fsel {
428
+ height: 36px;
429
+ padding: 0 8px;
430
+ border-radius: 6px;
431
+ border: 1px solid var(--border-color);
432
+ background: var(--bg);
433
+ color: var(--ink);
434
+ font-size: .82rem;
435
+ outline: none;
436
+ }
437
+ .fsel:focus { outline: 2px solid var(--brand); outline-offset: 1px; }
438
+
439
+ .topfail-row { display: flex; align-items: center; gap: 8px; font-size: .78rem; }
440
+ .topfail-name {
441
+ font-family: var(--f-mono);
442
+ font-weight: 600;
443
+ color: var(--ink-1);
444
+ cursor: pointer;
445
+ text-decoration: underline;
446
+ min-width: 140px;
447
+ max-width: 280px;
448
+ overflow: hidden;
449
+ text-overflow: ellipsis;
450
+ white-space: nowrap;
451
+ }
452
+ .topfail-bar { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; min-width: 60px; }
453
+ .topfail-bar-fill { height: 100%; background: var(--red); transition: width .2s; }
454
+ .topfail-count { font-family: var(--f-mono); font-size: .72rem; color: var(--red); min-width: 50px; text-align: right; }
455
+ .topfail-fixes { font-family: var(--f-mono); font-size: .72rem; color: var(--green); }
456
+
457
+ .expanded-row td { background: var(--bg-alt); }
458
+
459
+ .ev-line {
460
+ padding: 6px 8px;
461
+ background: var(--bg);
462
+ border: 1px solid var(--border-color);
463
+ border-radius: 5px;
464
+ }
465
+ .ev-line-head {
466
+ display: flex;
467
+ align-items: center;
468
+ gap: 6px;
469
+ flex-wrap: wrap;
470
+ }
471
+ .ev-line-error {
472
+ margin-top: 4px;
473
+ font-size: .72rem;
474
+ color: var(--red);
475
+ font-family: var(--f-mono);
476
+ word-break: break-word;
477
+ white-space: pre-wrap;
478
+ background: rgba(239,68,68,.06);
479
+ padding: 4px 6px;
480
+ border-radius: 4px;
481
+ }
482
+ </style>