diff --git a/src/client/components/overviewPanel.tsx b/src/client/components/overviewPanel.tsx index 66a776e..64ec996 100644 --- a/src/client/components/overviewPanel.tsx +++ b/src/client/components/overviewPanel.tsx @@ -26,9 +26,15 @@ * range's sessions into the two donut cards the per-session Context tab * opens with — Token Stats (the composition-split billed volume) and Timing * Stats (the summed totals). The body is then a 3:7 column pair: the - * insight column (the activity heatmap) beside the session column (search, + * insight column (the activity heatmap over the scope's skill-load card) + * beside the session column (search, * group chips, and the card grid); the heatmap keeps its own fixed 8-week - * window and PINs the list to a picked day (the page's drill-down gesture). + * window and PINs both column mates to a picked day (the page's drill-down + * gesture). The skill card's row click is the column's second drill-down: + * the session column narrows to the pinned skill's loaders within the same + * scope, and the card's description/origin/path enrichment rides the + * plugin's skill-catalog route (client/skills.ts), read per open off the + * current session's workspace. * A session card click jumps to that session through the harness's own * selection verb (openSessionVia), whose navigation returns the center column * to the conversation. @@ -41,12 +47,14 @@ import { useModelPrices } from '../modelPrices' import { aggregateDays, filterRows, groupCountsOf, inGroup, kpisOf, pageOf, refreshSessions, requestActivityBackfill, rowsOfSnapshot, - sessionGroupsOf, sessionsSnapshotOf, sortRows, + rowLoadedSkill, sessionGroupsOf, sessionsSnapshotOf, skillLoadsOf, sortRows, UNGROUPED_KEY, workspacesSnapshotOf, - type OverviewRange, type OverviewRow, type OverviewSort, + type OverviewRange, type OverviewRow, type OverviewSort, type SkillSort, } from '../overview' import { openSessionVia, type ClientCtx } from '../services' +import { readSkillCatalog } from '../skills' import { openPluginSettings } from '../settingsJump' +import type { SkillInfo } from '../../shared/types' import type { ViewKit } from '../viewkit' import { makeBalanceCapsule } from './balanceCapsule' import { makeErrorBoundary } from './errorBoundary' @@ -57,6 +65,7 @@ import { makeOverviewTokens } from './overviewTokens' import { makeOverviewUsage } from './overviewUsage' import { makeStatsTiming } from './statsTiming' import { makeOverviewCard } from './overviewCard' +import { makeOverviewSkills } from './overviewSkills' import { IconSettings } from '../primitives' export interface OverviewPanelProps { @@ -83,6 +92,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie const OverviewTokens = makeOverviewTokens(kit, Donut) const StatsTiming = makeStatsTiming(kit, Donut) const OverviewUsage = makeOverviewUsage(kit) + const OverviewSkills = makeOverviewSkills(kit) const ErrorBoundary = makeErrorBoundary(t) /** The display currency follows the active locale (zh → CNY), read per render — the slot outlet re-renders on a locale switch. */ @@ -101,6 +111,8 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie const [day, setDay] = useState(null) const [query, setQuery] = useState('') const [group, setGroup] = useState(null) + const [skill, setSkill] = useState(null) + const [skillSort, setSkillSort] = useState('loads') const [sort, setSort] = useState('recent') const [metric, setMetric] = useState('steps') const [page, setPage] = useState(0) @@ -108,6 +120,22 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie const rows = useMemo(() => rowsOfSnapshot(snapshot, wsSnapshot), [snapshot, wsSnapshot]) const groups = useMemo(() => sessionGroupsOf(wsSnapshot), [wsSnapshot]) + const [catalog, setCatalog] = useState | null>(null) + + // The skill card's metadata read: the registry resolves per SESSION (the + // workspace selects the project layer, the preset selects the discovery + // scope), so the current session anchors it; a session-less list still + // serves the global layers. A switch re-reads; a failed read leaves the + // card unenriched rather than stale. + const currentRow = (rows ?? []).find(row => row.current) + const currentCwd = currentRow?.cwd + const currentId = currentRow?.id + useEffect(() => { + let alive = true + setCatalog(null) + void readSkillCatalog(currentCwd, currentId).then((next) => { if (alive && next !== null) setCatalog(next) }) + return () => { alive = false } + }, [currentCwd, currentId]) // On mount (the panel's open): summon the host's projection warm-up (this // page is the rows' only reader — one pass per host process) and re-pull @@ -118,7 +146,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie }, []) // Any filter change re-anchors the pager at the first page. - useEffect(() => { setPage(0) }, [range, day, query, group, sort]) + useEffect(() => { setPage(0) }, [range, day, query, group, skill, sort]) const currency = activeCurrency() const now = Date.now() @@ -136,7 +164,10 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie ? [...counts, { key: group, count: 0 }] : counts const visible = sortRows( - group === null ? scoped : scoped.filter(row => inGroup(row, group, groups)), + (group === null ? scoped : scoped.filter(row => inGroup(row, group, groups))) + // The skill card's row pin narrows the list to the skill's loaders + // within the card's own scope (the range floor plus the pinned day). + .filter(row => skill === null || rowLoadedSkill(row, skill, { range, day }, now)), sort, ) const paged = pageOf(visible, page) @@ -253,6 +284,22 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie + {/* The activity column's second card: the scope's skill + loads off the rows' family ledgers (skillLoadsOf). The + scope is the range group plus the heatmap's pinned day + — the column's own drill-down — never the list's search + or group chips (those are list-local). A row pin narrows + the session list to the skill's loaders. */} +
@@ -264,6 +311,11 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie {t('ov.list.dayFilter', { day })} × )} + {skill !== null && ( + + )} >> 0 + return SKILL_COLORS[hash % SKILL_COLORS.length] +} + +/** + * The origin bucket's display label: the registry's known discovery sources + * localize; an unknown or provider-named source displays raw (it is the + * registry's data, not UI chrome), and a source-less entry has no label. + */ +function originLabelOf(t: ViewKit['t'], info: SkillInfo): string { + const source = info.source + if (source === 'project-dsh' || source === 'project-agents') return t('ov.skills.src.project') + if (source === 'user-dsh' || source === 'user-agents') return t('ov.skills.src.user') + if (source === 'bundled') return t('ov.skills.src.bundled') + return source ?? '' +} + +/** The description's display form: whitespace flattened, capped, ellipsized; '' when there is nothing to show. */ +function descriptionOf(info: SkillInfo): string { + const flat = info.description.replace(/\s+/g, ' ').trim() + return flat.length > MAX_DESC ? flat.slice(0, MAX_DESC) + '…' : flat +} + +/** The usage pair's one-line form (loads · sessions), shared by the hover card's footer and the detail grid. */ +function usageLineOf(kit: ViewKit, s: SkillLoadStat): string { + return `${kit.t('ov.skills.loads', { n: kit.fmt(s.loads) })} · ${kit.t('ov.skills.sessions', { n: kit.fmt(s.sessions) })}` +} + +/** The last load's exact stamp (day + clock), resilient to a hostile instant. */ +function lastLineOf(kit: ViewKit, s: SkillLoadStat): string { + return `${dayKeyOf(s.last) ?? ''} ${kit.fmtTime(s.last)}`.trim() +} + +/** The load stats' one-line form for the hover card's footer. */ +function statsLineOf(kit: ViewKit, s: SkillLoadStat): string { + return `${usageLineOf(kit, s)} · ${lastLineOf(kit, s)}` +} + +/** The shared head of both registers: the color dot, the name, and the origin chip when the catalog knows one. */ +function DetailHead(props: { name: string; origin: string }): ReactElement { + return ( +
+ + {props.name} + {props.origin !== '' && {props.origin}} +
+ ) +} + +/** The hovered row's anchor geometry (a plain record so state identity means anchor identity). */ +interface HoverRow { + name: string + rect: { left: number; top: number; width: number; bottom: number } +} + +export interface OverviewSkillsProps { + /** The scoped aggregation (skillLoadsOf), already sorted. */ + stats: SkillLoadStat[] + /** The heatmap's pinned day, when the column is filtered to one — the title names it. */ + day?: string | null + /** The skill the session list is pinned to, when one is. */ + selected?: string | null + /** Row-pin relay: the clicked skill, or null to release the active pin. */ + onSelect?: (name: string | null) => void + /** The picked row ordering; omission reads as 'default'. */ + sort?: SkillSort + /** Sort-toggle relay. */ + onSort?: (sort: SkillSort) => void + /** The panel's catalog read, joined by name; null while unread or unserved. */ + catalog?: ReadonlyMap | null + /** The render instant behind the rows' relative times. */ + now: number +} + +export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) => ReactElement { + const { t, fmt } = kit + return function OverviewSkills(props: OverviewSkillsProps): ReactElement { + const { stats } = props + const total = stats.reduce((sum, s) => sum + s.loads, 0) + // The bar's denominator is the heaviest row's tally, wherever the sort + // put it — keying on the first row overflows the card the moment another + // ordering (sessions / recent) leads with a lighter row. + const max = stats.reduce((top, s) => Math.max(top, s.loads), 0) + + // ---- the hover card --------------------------------------------------- + const [hoverRow, setHoverRow] = useState(null) + const [spot, setSpot] = useState<{ forRow: HoverRow; left: number; top: number } | null>(null) + const bubbleRef = useRef(null) + const hoverStat = hoverRow === null ? undefined : stats.find(s => s.name === hoverRow.name) + + // Any scroll or resize moves every anchor — retract rather than float + // detached (the page's hoverTip zone's own rule). + useEffect(() => { + if (hoverRow === null) return undefined + const retract = (): void => { setHoverRow(null) } + window.addEventListener('resize', retract) + window.addEventListener('scroll', retract, true) + return () => { + window.removeEventListener('resize', retract) + window.removeEventListener('scroll', retract, true) + } + }, [hoverRow]) + + // Measure once per shown card (the spot keys on the hover record, so a + // same-row re-render never re-hides): center on the row, clamp inside the + // viewport, flip below when above overflows. + useLayoutEffect(() => { + if (hoverRow === null) return + const el = bubbleRef.current + /* v8 ignore next -- the portal commits its ref before layout effects run, + * so a null ref here is unreachable; kept as a guard so a future portal + * change cannot crash the page. */ + if (el === null) return + const w = el.offsetWidth + const h = el.offsetHeight + const { rect } = hoverRow + const left = Math.max(TIP_MARGIN, Math.min(rect.left + rect.width / 2 - w / 2, window.innerWidth - w - TIP_MARGIN)) + const top = rect.top - h - TIP_GAP < TIP_MARGIN ? rect.bottom + TIP_GAP : rect.top - h - TIP_GAP + setSpot({ forRow: hoverRow, left, top }) + }, [hoverRow]) + + // ---- the pinned skill's copy flash ------------------------------------ + const [copied, setCopied] = useState(false) + const timer = useRef | null>(null) + // A pin change (or a release) drops any lingering flash; the pending timer clears on unmount. + useEffect(() => { setCopied(false) }, [props.selected]) + useEffect(() => () => { if (timer.current !== null) clearTimeout(timer.current) }, []) + const copyPath = async (path: string): Promise => { + if (await copySkillPath(path)) { + setCopied(true) + if (timer.current !== null) clearTimeout(timer.current) + timer.current = setTimeout(() => { setCopied(false) }, COPIED_MS) + } + } + + // ---- the pinned skill's detail ---------------------------------------- + // Expanded inline right under its row — a long list would bury a bottom + // block. The description leads (it answers what the skill IS); the facts + // follow as a two-column label grid — origin, usage, last load, and the + // full path as a click-to-copy value — so nothing floats unlabeled. A pin + // whose row left the visible scope (or the render bound) falls back to a + // detached block at the card's bottom, head row included; a catalog-less + // pin with in-scope tallies still shows its usage rows. + const selected = props.selected + const pinnedStat = selected === null || selected === undefined + ? undefined + : stats.find(s => s.name === selected) + const pinnedInfo = selected === null || selected === undefined + ? undefined + : props.catalog?.get(selected) + const detail = selected === null || selected === undefined || (pinnedInfo === undefined && pinnedStat === undefined) + ? null + : { + name: selected, + origin: pinnedInfo === undefined ? '' : originLabelOf(t, pinnedInfo), + description: pinnedInfo === undefined ? '' : descriptionOf(pinnedInfo), + path: pinnedInfo?.path, + } + // The copy value's path, narrowed once (a const's narrowing holds in the closure). + const detailPath = detail?.path + const detailBlock = detail === null ? null : ( +
+ {pinnedStat === undefined && } + {detail.description !== '' &&

{detail.description}

} +
+ {detail.origin !== '' && ( + + {t('ov.skills.k.source')} + {detail.origin} + + )} + {pinnedStat !== undefined && ( + + {t('ov.skills.k.usage')} + {usageLineOf(kit, pinnedStat)} + {t('ov.skills.k.last')} + {lastLineOf(kit, pinnedStat)} + + )} + {detailPath !== undefined && ( + + {t('ov.skills.k.path')} + + + )} +
+
+ ) + // Whether the pinned skill's own row renders (in scope and inside the + // render bound) — the inline seat exists only then. + const pinnedRowVisible = selected !== null && selected !== undefined + && stats.slice(0, MAX_ROWS).some(s => s.name === selected) + + const hoverTip = hoverStat === undefined ? null : (() => { + const info = props.catalog?.get(hoverStat.name) + const origin = info === undefined ? '' : originLabelOf(t, info) + const description = info === undefined ? '' : descriptionOf(info) + const pinned = props.selected === hoverStat.name + const shown = spot !== null && spot.forRow === hoverRow ? spot : null + return createPortal( + , + document.body, + ) + })() + + const sort = props.sort ?? 'loads' + return ( +
+
+ {t('stats.skills')} + {props.day !== null && props.day !== undefined && {props.day}} + {/* The sort toggle pins the title row's right corner (the gran's own + auto margin); a lone row leaves nothing to order. */} + {stats.length > 1 && ( +
+ {SKILL_SORTS.map(so => ( + + ))} +
+ )} +
+ {/* The scope's totals live under the title, clear of the toggle. */} + {stats.length > 0 && ( +
+ {t('ov.skills.summary', { skills: fmt(stats.length), loads: fmt(total) })} +
+ )} + {stats.length === 0 ? ( +
{t('ov.skills.empty')}
+ ) : ( +
+ {stats.slice(0, MAX_ROWS).map((s) => { + const color = skillColorOf(s.name) + const pinned = props.selected === s.name + // The bar's percentage resolves against the row's padding box + // but its track is the content box — subtract the horizontal + // insets (6px + 6px) or 100% would poke through the unit's ring. + const share = Math.round(s.loads / Math.max(max, 1) * 100) + const bar = + const rowButton = ( + + ) + // The pinned row and its detail read as one unit — a shared + // fill and a single ring around the pair (`.lc-ov-skill-unit`), + // never two stacked boxes with a seam. + return pinned + ?
{rowButton}{detailBlock}
+ : {rowButton} + })} + {stats.length > MAX_ROWS && ( +
{t('ov.skills.more', { n: stats.length - MAX_ROWS })}
+ )} +
+ )} + {/* The detached seat: the pinned skill's row is out of the visible + scope (or past the render bound) — the pin still gets its detail. */} + {!pinnedRowVisible && detailBlock} + {hoverTip} +
+ ) + } +} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index a7e527d..16a8198 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -395,6 +395,25 @@ export const DICT_ZH: Record = { 'ov.heat.mon.10': '10月', 'ov.heat.mon.11': '11月', 'ov.heat.mon.12': '12月', + 'ov.skills.empty': '统计范围内暂无技能加载 — 加载技能后,这里会按名称列出次数与最近加载', + 'ov.skills.more': '另有 {n} 个技能', + 'ov.skills.loads': '{n} 次加载', + 'ov.skills.summary': '{skills} 个技能 · {loads} 次加载', + 'ov.skills.sortLabel': '技能排序', + 'ov.skills.sort.loads': '次数', + 'ov.skills.sort.recent': '最近', + 'ov.skills.sessions': '{n} 个会话', + 'ov.skills.clickFilter': '点击筛选加载过它的会话', + 'ov.skills.clickRelease': '再次点击取消筛选', + 'ov.skills.src.project': '项目', + 'ov.skills.src.user': '用户', + 'ov.skills.src.bundled': '内置', + 'ov.skills.k.source': '来源', + 'ov.skills.k.usage': '用量', + 'ov.skills.k.last': '最近', + 'ov.skills.k.path': '路径', + 'ov.skills.copyPath': '点击复制路径', + 'ov.skills.copied': '已复制', 'ov.list.title': '会话', 'ov.list.search': '搜索标题 / 目录 / 最后消息…', 'ov.list.sortLabel': '排序', @@ -406,6 +425,7 @@ export const DICT_ZH: Record = { 'ov.list.noData': '暂无上下文数据 — 该会话产生模型请求后才会统计', 'ov.list.dayFilter': '{day} 当日活跃', 'ov.list.dayClear': '清除日期筛选', + 'ov.list.skillClear': '清除技能筛选', 'ov.list.pager': '会话分页', 'ov.list.page': '第 {n} / {total} 页', 'ov.list.prev': '上一页', @@ -812,6 +832,25 @@ export const DICT_EN: Record = { 'ov.heat.mon.10': 'Oct', 'ov.heat.mon.11': 'Nov', 'ov.heat.mon.12': 'Dec', + 'ov.skills.empty': 'No skill loads in this range — loaded skills appear here with their counts and last load', + 'ov.skills.more': '{n} more skills', + 'ov.skills.loads': '{n} loads', + 'ov.skills.summary': '{skills} skills · {loads} loads', + 'ov.skills.sortLabel': 'Skill sort', + 'ov.skills.sort.loads': 'Loads', + 'ov.skills.sort.recent': 'Recent', + 'ov.skills.sessions': '{n} sessions', + 'ov.skills.clickFilter': 'Click to filter the sessions that loaded it', + 'ov.skills.clickRelease': 'Click again to release the filter', + 'ov.skills.src.project': 'Project', + 'ov.skills.src.user': 'User', + 'ov.skills.src.bundled': 'Built-in', + 'ov.skills.k.source': 'Source', + 'ov.skills.k.usage': 'Usage', + 'ov.skills.k.last': 'Last', + 'ov.skills.k.path': 'Path', + 'ov.skills.copyPath': 'Click to copy the path', + 'ov.skills.copied': 'Copied', 'ov.list.title': 'Sessions', 'ov.list.search': 'Search title / path / last message…', 'ov.list.sortLabel': 'Sort', @@ -823,6 +862,7 @@ export const DICT_EN: Record = { 'ov.list.noData': 'No context data yet — stats appear once the session makes model requests', 'ov.list.dayFilter': 'active on {day}', 'ov.list.dayClear': 'Clear the day filter', + 'ov.list.skillClear': 'Clear the skill filter', 'ov.list.pager': 'Session pagination', 'ov.list.page': 'Page {n} of {total}', 'ov.list.prev': 'Previous page', diff --git a/src/client/overview.ts b/src/client/overview.ts index d7efbb7..9f391e3 100644 --- a/src/client/overview.ts +++ b/src/client/overview.ts @@ -21,13 +21,14 @@ */ import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { dayKeyOf } from '../shared/days' import { billedParts } from './categories' import { cacheHitPercent } from './format' import { estimateSessionCost, mergeCostUsage } from './cost' import type { CostCurrency, ModelBook } from './cost' import { agentRowOf } from './agentTree' import { activityOf, asRecord, timelineOf, type ClientCtx, type SessionsFace } from './services' -import type { ContextActivity, ContextTimeline, SessionCostUsage, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types' +import type { ActivityDay, ContextActivity, ContextTimeline, SessionCostUsage, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types' /** One session-list row joined with its (sanitized) projection values. */ export interface OverviewRow { @@ -276,8 +277,10 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie /** * Merge several sessions' daily ledgers into one family's: per day the tokens * and requests sum and the pricing records merge (each member's own entry, - * same day — the fee prices off the same book downstream either way). Null - * when no member carries a ledger, matching a lone session's day-less state. + * same day — the fee prices off the same book downstream either way), and the + * skill tables merge name by name (loads sum, last-load instant takes the + * max). Null when no member carries a ledger, matching a lone session's + * day-less state. */ function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | null { const days: ContextActivity['days'] = {} @@ -291,7 +294,12 @@ function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | n const entry = activity.days[key] const prev = byKey[key] if (prev === undefined) { - days[key] = { tokens: entry.tokens, requests: entry.requests, ...(entry.cost !== undefined ? { cost: entry.cost } : {}) } + days[key] = { + tokens: entry.tokens, + requests: entry.requests, + ...(entry.cost !== undefined ? { cost: entry.cost } : {}), + ...(entry.skills !== undefined ? { skills: entry.skills } : {}), + } } else { prev.tokens += entry.tokens prev.requests += entry.requests @@ -299,12 +307,25 @@ function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | n /* v8 ignore next 1 -- a merge with a defined input never comes back empty. */ prev.cost = mergeCostUsage(prev.cost, entry.cost) ?? entry.cost } + if (entry.skills !== undefined) prev.skills = mergeSkillTallies(prev.skills, entry.skills) } } } return any ? { days } : null } +/** Two days' skill tables merged name by name: loads sum, the last-load instant takes the max. */ +function mergeSkillTallies(a: ActivityDay['skills'], b: NonNullable): NonNullable { + // Widened honestly: a Record index read can miss at runtime. + const out: Record = { ...a } + for (const name of Object.keys(b)) { + const tally = b[name] + const prev = out[name] + out[name] = prev === undefined ? tally : { n: prev.n + tally.n, last: Math.max(prev.last, tally.last) } + } + return out as NonNullable +} + // ---- range / filter / sort ------------------------------------------------- export type OverviewRange = '24h' | '7d' | '30d' | 'all' @@ -406,6 +427,99 @@ export function pageOf(rows: readonly T[], page: number): { items: T[]; index // ---- aggregations ---------------------------------------------------------- +/** One skill's loads across the scoped rows: its tally, its loader count, and its last load instant. */ +export interface SkillLoadStat { + name: string + loads: number + /** How many session rows (agent families — the same granularity the list filter reads) loaded it in scope. */ + sessions: number + /** The last load's instant (epoch ms) — the row's relative-time label. */ + last: number +} + +/** The skill card's row orderings (the title's segmented toggle); omission reads as 'loads'. */ +export type SkillSort = 'loads' | 'recent' + +/** + * The scope's ledger-day predicate: the heatmap's pinned day admits exactly + * that day, else the range's day floor admits its own and later days (the + * ledger is day-grained: the 24h window's start day counts whole, the same + * resolution the heatmap reads at); 'all' admits every day. + */ +function skillDayPredicate(opts: { range: OverviewRange; day: string | null }, now: number): (key: string) => boolean { + if (opts.day !== null) return key => key === opts.day + const start = rangeStartOf(opts.range, now) + const floor = start === null ? null : dayKeyOf(start) + return floor === null ? () => true : key => key >= floor +} + +/** + * The Insights page's skill card data: every scoped row's family ledger (the + * activity merge already folds the subtree) folded name by name over the + * ledger days the scope admits. Each name tallies its loads, counts its + * loader rows once per row (a row loading it on two days is one loader), and + * keeps the freshest load instant. The sort ranks by the picked key, the + * remaining keys tiebreak volume-first then recent-first, and the name is the + * final tiebreak so every ordering is stable. + */ +export function skillLoadsOf( + rows: readonly OverviewRow[], + opts: { range: OverviewRange; day: string | null; sort?: SkillSort }, + now: number, +): SkillLoadStat[] { + const admit = skillDayPredicate(opts, now) + const stats = new Map() + for (const row of rows) { + const days = row.activity?.days + if (days === undefined) continue + // A row counts once per name toward the loader tally, however many of its + // admitted days carry the name. + const rowLoaded = new Set() + for (const key of Object.keys(days)) { + if (!admit(key)) continue + const skills = days[key].skills + if (skills === undefined) continue + for (const name of Object.keys(skills)) { + const tally = skills[name] + const prev = stats.get(name) + if (prev === undefined) stats.set(name, { name, loads: tally.n, sessions: 1, last: tally.last }) + else { + prev.loads += tally.n + prev.last = Math.max(prev.last, tally.last) + if (!rowLoaded.has(name)) prev.sessions += 1 + } + rowLoaded.add(name) + } + } + } + // Names are unique across rows (the map keys), so the name tiebreak never ties. + const byName = (a: SkillLoadStat, b: SkillLoadStat): number => (a.name < b.name ? -1 : 1) + const out = [...stats.values()] + switch (opts.sort ?? 'loads') { + case 'recent': return out.sort((a, b) => (b.last - a.last) || (b.loads - a.loads) || byName(a, b)) + default: return out.sort((a, b) => (b.loads - a.loads) || (b.last - a.last) || byName(a, b)) + } +} + +/** + * Whether the row's family ledger loaded the named skill within the scope — + * the session-list filter behind the skill card's click-to-drill-down. + */ +export function rowLoadedSkill( + row: OverviewRow, + name: string, + opts: { range: OverviewRange; day: string | null }, + now: number, +): boolean { + const admit = skillDayPredicate(opts, now) + const days = row.activity?.days + if (days === undefined) return false + for (const key of Object.keys(days)) { + if (admit(key) && days[key].skills?.[name] !== undefined) return true + } + return false +} + /** The merged billed-bucket totals behind the KPI band and the cost estimate. */ export interface UsageTotals { input: number diff --git a/src/client/services.ts b/src/client/services.ts index a23f748..0bff59f 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -765,13 +765,45 @@ export function activityOf(value: unknown): ContextActivity | null { const proved = costOf(entry.cost) const cost = proved !== undefined && Object.keys(proved).length > 0 ? proved : undefined if (entry.cost !== undefined && cost === undefined) dirty = true - days[key] = { tokens, requests, ...(cost !== undefined ? { cost } : {}) } + // The day's skill table (additive-optional): per-name re-proved — a + // malformed tally drops just that name, a malformed record drops whole. + const skills = skillTalliesOf(entry.skills) + if (entry.skills !== undefined && skills === undefined) dirty = true + days[key] = { + tokens, + requests, + ...(cost !== undefined ? { cost } : {}), + ...(skills !== undefined ? { skills } : {}), + } } // Fully well-formed: pass the delivered value through untouched (cheap, and // reference-stable for the selector equality); otherwise the sanitized copy. return dirty ? { days } : data as unknown as ContextActivity } +/** + * One day entry's skill-load table, re-proved name by name: a tally needs a + * finite non-negative count and a finite load instant; anything less drops + * just that name. Absent/non-record stays undefined (an older host's day, or + * a load-less one); an empty-after-scrubbing record drops whole. + */ +function skillTalliesOf(value: unknown): ActivityDay['skills'] | undefined { + const data = asRecord(value) + if (data === null || Array.isArray(data)) return undefined + const skills: Record = {} + let any = false + for (const name of Object.keys(data)) { + const tally = asRecord(data[name]) + const n = tally?.n + const last = tally?.last + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0 + || typeof last !== 'number' || !Number.isFinite(last)) continue + skills[name] = { n, last } + any = true + } + return any ? skills : undefined +} + export interface TriggerCandidate { name: string /** Display title; the name itself when absent (a differing title renders the name as a trailing alias). */ diff --git a/src/client/skills.ts b/src/client/skills.ts new file mode 100644 index 0000000..065c697 --- /dev/null +++ b/src/client/skills.ts @@ -0,0 +1,105 @@ +/** + * The skill catalog behind the Insights page's skill card: one POST to the + * plugin's `/api/dsh-context/skills` route (host/skills.ts) per panel open, + * anchored on the current session (its workspace selects the registry's + * project layer; its preset selects the discovery scope). The card joins the + * answer by name onto the activity ledger's load tallies, so a missing or + * failed read is no error state — the rows render their tallies unenriched. + */ + +import type { SkillInfo } from '../shared/types' +import { asRecord } from './services' + +// The skills route of host/skills.ts — re-declared here (the client bundle +// inlines every import, and the host module must never reach it). Same-origin +// POST under the harness's authenticated `/api` fence. +const SKILLS_ROUTE = '/api/dsh-context/skills' + +/** + * Narrow the route's payload to render-safe entries (the boundary rigor + * every parser owes untrusted input): each entry's name is the join key and + * must prove itself, the description defaults to empty, path and source ride + * only when proved — an entry failing the shape drops whole and the readable + * siblings keep serving. + */ +export function skillInfoListOf(value: unknown): SkillInfo[] | null { + const list = asRecord(value)?.skills + if (!Array.isArray(list)) return null + const out: SkillInfo[] = [] + for (const item of list) { + const record = asRecord(item) + if (record === null || typeof record.name !== 'string' || record.name === '') continue + out.push({ + name: record.name, + description: typeof record.description === 'string' ? record.description : '', + ...(typeof record.path === 'string' && record.path !== '' ? { path: record.path } : {}), + ...(typeof record.source === 'string' && record.source !== '' ? { source: record.source } : {}), + }) + } + return out +} + +/** + * One route read for one viewing context, narrowed through `skillInfoListOf` + * into the card's name-keyed join map. The session id lets the host resolve + * the session's workspace AND preset scope (web compositions mount skill + * discovery on agent presets — a cwd alone lists only the global layers); + * the cwd rides as the fallback when the session cannot be observed. Never + * rejects and never reports an empty answer: a served catalog with no usable + * entry reads the same as no route at all (`null` — the rows render + * unenriched either way). Later names win the map slot, matching the + * registry's own last-wins merge. + */ +export async function readSkillCatalog( + cwd: string | undefined, + sessionId: string | undefined, +): Promise | null> { + try { + const response = await fetch(SKILLS_ROUTE, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...(cwd === undefined ? {} : { cwd }), + ...(sessionId === undefined ? {} : { sessionId }), + }), + }) + if (!response.ok) return null + const body = asRecord(await response.json()) + const list = body === null || body.ok !== true ? null : skillInfoListOf(body.value) + if (list === null || list.length === 0) return null + return new Map(list.map(info => [info.name, info])) + } catch { + return null + } +} + +/** The clipboard write behind the card's path footer (test seam; the real write). */ +type TextCopier = (text: string) => Promise + +/** The clipboard write through the platform API (absent off a secure context). */ +const defaultCopier: TextCopier = async (text) => { + const clipboard = (globalThis.navigator as { clipboard?: { writeText?: unknown } } | undefined)?.clipboard + if (typeof clipboard?.writeText !== 'function') throw new Error('clipboard unavailable') + await (clipboard.writeText as (text: string) => Promise).call(clipboard, text) +} + +let copier: TextCopier = defaultCopier + +/** Test seam: replace the clipboard write; null restores the default. */ +export function setSkillCopier(next: TextCopier | null): void { + copier = next ?? defaultCopier +} + +/** + * Copy the skill's path for the card's footer click. Resolves whether the + * write landed, so the footer's "copied" flash never claims a write that + * failed (an insecure context, a denied permission). + */ +export async function copySkillPath(path: string): Promise { + try { + await copier(path) + return true + } catch { + return false + } +} diff --git a/src/client/styles/overview.css b/src/client/styles/overview.css index 0d3668d..bc250e1 100644 --- a/src/client/styles/overview.css +++ b/src/client/styles/overview.css @@ -213,11 +213,78 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col .lc-heat-legend { display: flex; align-items: center; justify-content: flex-end; gap: 3px; padding-top: 6px; } .lc-heat-key { font-size: 9px; color: var(--dsw-alias-label-secondary); } +/* ---- the skill card (the activity column's second card): one row per + scoped skill — a name-hashed color dot, the name, the load tally, the last + load's relative time, and a proportional hairline bar along the row's + bottom edge (share of the heaviest row's tally, so no ordering can push a + bar past the card edge). Rows are the column's drill-down: a click pins + the session list to the skill's loaders, and the pinned row wraps with its + detail into one unit carrying the ring. The name ellipsizes (its full + text rides the row's hover tip); the two figures hold a fixed right column + so the tally and the time never crush it. ---- */ +.lc-ov-skills { display: flex; flex-direction: column; gap: 2px; } +/* The scope's totals sit under the title, pulled into the title's bottom + margin so the pair reads as one head; the sort toggle stays up in the + title row's right corner. */ +.lc-ov-skills-sub { margin: -4px 0 8px; } +.lc-ov-skill { position: relative; display: flex; align-items: baseline; gap: 7px; min-width: 0; width: 100%; border: 0; border-radius: 6px; padding: 2px 6px 4px; background: transparent; font: inherit; font-size: 12px; line-height: 1.7; color: inherit; text-align: left; cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } +.lc-ov-skill:hover { background: var(--dsw-alias-bg-layer-2); } +/* The pinned row and its detail read as one unit: the fill and the single + ring live on the wrapper (the ring follows the rounded corners), the parts + inside go quiet — no seam, no double outline. */ +.lc-ov-skill-unit { background: var(--dsw-alias-bg-layer-2); border-radius: 8px; box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-blue-500) 45%, transparent); } +.lc-ov-skill-unit .lc-ov-skill-detail { margin-top: 0; padding: 4px 6px 8px; background: transparent; } +.lc-ov-skill-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; align-self: center; } +.lc-ov-skill-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-primary); } +.lc-ov-skill-count { flex: none; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary); } +.lc-ov-skill-last { flex: none; min-width: 56px; text-align: right; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; } +/* The share hairline: absolute off the row's bottom padding, so it never + moves the text baseline; the color-mix keeps it a wash under both themes. + The JS width subtracts the row's horizontal insets (6px + 6px), so 100% + stops at the content edge instead of poking through the pinned unit's ring. */ +.lc-ov-skill-bar { position: absolute; left: 6px; bottom: 1px; height: 2px; border-radius: 1px; opacity: 0.45; pointer-events: none; } +.lc-ov-skill-more { padding-top: 4px; font-size: 11px; color: var(--dsw-alias-label-secondary); } +/* The skill hover card (a structured `.lc-tip` bubble — the page's plain + pre-line chrome cannot hold a hierarchy) and the pinned skill's detail + block under the rows share the head (dot + name; the card adds the origin + chip) and the clamped description; the card adds the stats/action footer, + the block the fact grid. The detail registers wrap an over-long name in + full — the list row's ellipsis is the only truncation. */ +.lc-skilltip { position: fixed; z-index: 300; max-width: min(300px, 60vw); padding: 10px 12px; border-radius: 10px; white-space: normal; line-height: 1.5; } +.lc-skilld-head { display: flex; align-items: center; gap: 7px; } +.lc-skilld-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary); } +.lc-skilld-chip { flex: none; font-size: 10px; line-height: 1.5; color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 0 5px; white-space: nowrap; } +.lc-skilld-desc { margin: 6px 0 0; font-size: 11.5px; line-height: 1.55; color: var(--dsw-alias-label-secondary); overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } +.lc-skilld-path { margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-tertiary); } +/* The footer's two halves fold under width pressure (the bubble is portaled + to , outside the lc-ov query container — flex-wrap self-adapts, no + container query reaches it): the stats hold one unbroken line, the hint + drops to its own right-aligned line. */ +.lc-skilld-foot { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--dsw-alias-border-l2); font-size: 11px; color: var(--dsw-alias-label-secondary); } +.lc-skilld-foot > span:first-child { white-space: nowrap; } +.lc-skilld-hint { margin-left: auto; flex: none; color: var(--color-blue-500); white-space: nowrap; } +/* The pinned skill's detail: attached right under its row (a quiet inset + box), or detached at the card's bottom when the pinned row is out of the + visible scope. */ +.lc-ov-skill-detail { margin: 4px 0 2px; padding: 8px 10px 9px; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; } +.lc-ov-skill-detail > :first-child { margin-top: 0; } +/* The facts: a label column against a value column — every fact named, + nothing floating. Values ellipsis; the path value is the copy button. */ +.lc-skilld-grid { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; margin-top: 7px; font-size: 11px; line-height: 1.6; } +.lc-skilld-k { color: var(--dsw-alias-label-tertiary); white-space: nowrap; } +.lc-skilld-v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); } +.lc-skilld-pathv { display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; font: inherit; cursor: copy; text-align: left; } +.lc-skilld-pathv-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); } +.lc-skilld-pathv svg { flex: none; color: var(--dsw-alias-label-tertiary); transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } +.lc-skilld-pathv:hover .lc-skilld-pathv-text, .lc-skilld-pathv:hover svg { color: var(--dsw-alias-label-primary); } + /* ---- the session list head: title, count, day chip, search, sort ---- */ .lc-ov-list-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } .lc-ov-list-title { font-weight: 600; white-space: nowrap; } .lc-ov-list-count { color: var(--dsw-alias-label-secondary); font-size: 11px; font-variant-numeric: tabular-nums; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; padding: 1px 7px; } -.lc-ov-day-chip { border: 1px solid color-mix(in srgb, var(--color-blue-500) 45%, transparent); color: var(--color-blue-500); background: color-mix(in srgb, var(--color-blue-500) 10%, transparent); border-radius: 999px; font-size: 11px; line-height: 1.4; padding: 1px 10px; cursor: pointer; font-family: inherit; white-space: nowrap; } +/* The day/skill pin chips truncate an over-long skill name instead of + bursting the list head. */ +.lc-ov-day-chip { border: 1px solid color-mix(in srgb, var(--color-blue-500) 45%, transparent); color: var(--color-blue-500); background: color-mix(in srgb, var(--color-blue-500) 10%, transparent); border-radius: 999px; font-size: 11px; line-height: 1.4; padding: 1px 10px; cursor: pointer; font-family: inherit; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .lc-ov-search { flex: 1 1 150px; min-width: 110px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); outline: none; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } .lc-ov-search:focus { border-color: var(--dsw-alias-label-secondary); } .lc-ov-search::placeholder { color: var(--dsw-alias-label-secondary); } diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts index cd06835..d677811 100644 --- a/tests/client/components/overviewPanel.spec.ts +++ b/tests/client/components/overviewPanel.spec.ts @@ -11,7 +11,7 @@ import { makeOverviewPanel } from '../../../src/client/components/overviewPanel' import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices' import { dayKeyOf } from '../../../src/shared/days' import { TestClientCtx, asClientCtx } from '../helpers/harness' -import { click, flush, makeKit, mount, query, queryAll, text, until, type Mounted } from '../helpers/kit' +import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, until, type Mounted } from '../helpers/kit' const PROVIDERS = { deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 1, output: 2, cache_read: 0.1 } } } }, @@ -237,6 +237,54 @@ describe('OverviewPanel', () => { await m.unmount() }) + test('the skill card pins the session list to the skill’s loaders; the chip and the row release it', async () => { + const ctx = makeCtx() + // Session a loaded 'tdd' today; b's ledger carries no skills. + const snap = sessionsSnapshot() + const aDays = (snap.byId.a.projectionValues as { contextActivity: { days: Record> } }).contextActivity.days + aDays[TODAY] = { ...aDays[TODAY], skills: { tdd: { n: 2, last: NOW - 1000 } } } + const skillPosts: unknown[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + const route = String(url) + if (route.endsWith('/skills')) { + skillPosts.push(init?.body === undefined ? undefined : JSON.parse(String(init.body))) + return { + ok: true, + json: async () => ({ ok: true, value: { skills: [{ name: 'tdd', description: 'Test-driven.', path: '/p/tdd/SKILL.md', source: 'user-agents' }] } }), + } + } + return { ok: true, json: async () => ({}) } + }) + const { m } = await openPanel(ctx, { useSessions: useHookOf(snap) }) + await flush() // the catalog read lands + assert.deepEqual(skillPosts, [{ cwd: '/repo/alpha', sessionId: 'a' }], 'the catalog read anchors on the current session') + // The card lists the skill, enriched by the catalog (the hover card carries the description). + const skillRow = query(m.container, 'button.lc-ov-skill') + assert.ok(text(skillRow).includes('tdd')) + await hover(skillRow) + assert.ok(text(document.querySelector('#lc-skilltip-bubble') as HTMLElement).includes('Test-driven.'), 'the catalog’s description joins the hover card') + await unhover(skillRow) + // Pin: the grid narrows to the skill's loaders and the list-head chip shows the pin. + await click(skillRow) + const cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session') + assert.equal(cards.length, 1) + assert.ok(text(cards[0]).includes('alpha session')) + const chip = query(m.container, '.lc-ov-day-chip[title="Clear the skill filter"]') + assert.ok(text(chip).includes('Skill · tdd')) + // The pin's detail block rides the catalog. + assert.ok(text(query(m.container, '.lc-ov-skill-detail')).includes('/p/tdd/SKILL.md')) + // The chip clears the pin. + await click(chip) + assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2) + assert.equal(queryAll(m.container, '.lc-ov-skill-detail').length, 0, 'the detail block leaves with the pin') + // Re-pin and release via the row itself. + await click(query(m.container, 'button.lc-ov-skill')) + assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1) + await click(query(m.container, 'button.lc-ov-skill')) + assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2) + await m.unmount() + }) + test('the metric toggle re-prices the heatmap between steps and sessions', async () => { const ctx = makeCtx() const { m } = await openPanel(ctx) diff --git a/tests/client/components/overviewSkills.spec.ts b/tests/client/components/overviewSkills.spec.ts new file mode 100644 index 0000000..ec725e4 --- /dev/null +++ b/tests/client/components/overviewSkills.spec.ts @@ -0,0 +1,418 @@ +// The Insights page's skill card (src/client/components/overviewSkills.tsx): +// scope labelling, the color-dot/hairline row rendering, the click-to-pin +// relay, the structured hover card (head/description/path/footer, measure and +// flip, scroll/resize retract), the pinned skill's detail block with its +// copyable path line, the render bound's overflow line, and the empty note — +// rendered with real React over the real dictionaries. + +import { act, createElement as h } from 'react' +import assert from 'node:assert/strict' +import { afterEach, describe, test, vi } from 'vitest' +import { makeOverviewSkills, skillColorOf } from '../../../src/client/components/overviewSkills' +import type { SkillLoadStat } from '../../../src/client/overview' +import { setSkillCopier } from '../../../src/client/skills' +import type { SkillInfo } from '../../../src/shared/types' +import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit' + +const Skills = makeOverviewSkills(makeKit('en')) +const SkillsZh = makeOverviewSkills(makeKit('zh')) + +const NOW = Date.UTC(2026, 8, 20, 12) + +function stat(name: string, loads: number, last: number, sessions = 1): SkillLoadStat { + return { name, loads, sessions, last } +} + +/** A catalog fixture: the full entry, a path-less project entry, and an unknown-source entry with a long description. */ +const CATALOG: ReadonlyMap = new Map([ + ['tdd', { name: 'tdd', description: 'Test-driven development.\nWrite the test first.', path: '/home/u/.agents/skills/tdd/SKILL.md', source: 'user-agents' }], + ['grill-me', { name: 'grill-me', description: '', source: 'project-dsh' }], + ['custom-skill', { name: 'custom-skill', description: ` ${'x'.repeat(200)} `, path: '/opt/skills/custom.md', source: 'team-registry' }], +]) + +/** The portaled hover card (document.body), when shown. */ +function bubble(): HTMLElement | null { + return document.querySelector('#lc-skilltip-bubble') +} + +/** Pin a row's anchor geometry for the measure assertions (jsdom rects are all zero). */ +function mockRect(row: Element, rect: { left: number; top: number; width: number; bottom: number }): void { + Object.defineProperty(row, 'getBoundingClientRect', { configurable: true, value: () => ({ ...rect, right: rect.left + rect.width, height: 20 }) }) +} + +afterEach(() => { + setSkillCopier(null) +}) + +describe('skillColorOf', () => { + test('the palette pick is stable and palette-bound', () => { + assert.equal(skillColorOf('tdd'), skillColorOf('tdd')) + for (const name of ['tdd', 'grill-me', 'ponytail', 'ask-matt', 'a']) { + assert.ok(skillColorOf(name).startsWith('var(--color-'), name) + } + }) +}) + +describe('OverviewSkills', () => { + test('the empty scope renders the note, no summary, no detail block', async () => { + const m = await mount(h(Skills, { stats: [], now: NOW })) + assert.ok(text(m.container).includes('No skill loads in this range')) + assert.equal(queryAll(m.container, '.lc-ov-skills-sub').length, 0) + assert.equal(queryAll(m.container, '.lc-ov-skill').length, 0) + assert.equal(queryAll(m.container, '.lc-ov-skill-detail').length, 0, 'no detail without a pin') + await m.unmount() + }) + + test('each row shows the dot, the name, the tally, the last load’s relative time, and its share bar', async () => { + const m = await mount(h(Skills, { + stats: [stat('tdd', 12, NOW - 3 * 3_600_000), stat('grill-me', 3, NOW - 40_000)], + now: NOW, + })) + const rows = queryAll(m.container, 'button.lc-ov-skill') + assert.equal(rows.length, 2) + assert.equal(text(query(rows[0], '.lc-ov-skill-name')), 'tdd') + assert.equal(text(query(rows[0], '.lc-ov-skill-count')), '×12') + assert.equal(text(query(rows[0], '.lc-ov-skill-last')), '3h ago') + assert.equal(text(query(rows[1], '.lc-ov-skill-last')), 'just now') + // The dot and the bar carry the skill's stable palette color; the top row's bar spans full width. + // (jsdom's CSSOM drops var() from the shorthand property read — the attribute string is the truth.) + assert.ok(query(rows[0], '.lc-ov-skill-dot').getAttribute('style')?.includes(skillColorOf('tdd'))) + assert.ok(query(rows[0], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(100% - 12px)')) + assert.ok(query(rows[1], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(25% - 3px)'), 'the share of the heaviest row') + // The summary sub under the title names both figures — the distinct skills and the total loads. + assert.ok(text(query(m.container, '.lc-ov-skills-sub')).includes('2 skills · 15 loads')) + await m.unmount() + }) + + test('the share bar scales to the heaviest row wherever the ordering puts it — never past the content edge', async () => { + // A recent ordering can lead with a lighter row; the bar's denominator + // must still be the heaviest tally (a first-row denominator overflowed + // the card at 200%), and the width subtracts the row's horizontal insets + // so 100% stops at the content edge instead of crossing the unit's ring. + const m = await mount(h(Skills, { stats: [stat('light', 1, NOW), stat('heavy', 2, NOW)], now: NOW })) + const rows = queryAll(m.container, 'button.lc-ov-skill') + assert.ok(query(rows[0], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(50% - 6px)')) + assert.ok(query(rows[1], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(100% - 12px)')) + await m.unmount() + // A hostile all-zero scope divides safely into empty bars. + const zero = await mount(h(Skills, { stats: [stat('zero', 0, NOW)], now: NOW })) + assert.ok(query(zero.container, '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(0% - 0px)')) + await zero.unmount() + }) + + test('a row click pins and releases through onSelect; the pinned row wraps with its detail into one unit', async () => { + const picks: (string | null)[] = [] + const m = await mount(h(Skills, { + stats: [stat('tdd', 2, NOW), stat('grill-me', 1, NOW)], + selected: 'tdd', + onSelect: (name) => { picks.push(name) }, + now: NOW, + })) + const rows = queryAll(m.container, 'button.lc-ov-skill') + assert.ok((rows[0].parentElement as HTMLElement).className.includes('lc-ov-skill-unit'), 'the pinned row’s wrapper carries the ring') + assert.equal(rows[0].getAttribute('aria-pressed'), 'true') + assert.equal(rows[1].getAttribute('aria-pressed'), 'false') + await click(rows[1]) + assert.deepEqual(picks, ['grill-me']) + await click(rows[0]) + assert.deepEqual(picks, ['grill-me', null], 'clicking the pinned row releases the pin') + await m.unmount() + }) + + test('a row click without a relay only renders', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 2, NOW)], now: NOW })) + await click(query(m.container, 'button.lc-ov-skill')) + await m.unmount() + }) + + test('the hover card lays out the head, the description, the path, and the stats/action footer', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 2, NOW - 3_600_000)], catalog: CATALOG, now: NOW })) + assert.equal(bubble(), null, 'no card before a hover') + const row = query(m.container, 'button.lc-ov-skill') + await hover(row) + const tip = bubble() + assert.ok(tip, 'the card portals to ') + assert.equal(row.getAttribute('aria-describedby'), 'lc-skilltip-bubble') + assert.equal(text(query(tip!, '.lc-skilld-name')), 'tdd') + assert.equal(text(query(tip!, '.lc-skilld-chip')), 'User') + assert.equal(text(query(tip!, '.lc-skilld-desc')), 'Test-driven development. Write the test first.', 'the description flattens to one flow') + assert.equal(text(query(tip!, '.lc-skilld-path')), '/home/u/.agents/skills/tdd/SKILL.md') + assert.ok(/2 loads · 1 sessions · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(text(query(tip!, '.lc-skilld-foot')))) + assert.equal(text(query(tip!, '.lc-skilld-hint')), 'Click to filter the sessions that loaded it') + await unhover(row) + assert.equal(bubble(), null, 'leaving the row retracts the card') + assert.equal(row.getAttribute('aria-describedby'), null) + await m.unmount() + }) + + test('the bare hover card carries the stats and the hint without catalog lines', async () => { + const m = await mount(h(Skills, { stats: [stat('ghost', 3, NOW)], now: NOW })) + await hover(query(m.container, 'button.lc-ov-skill')) + const tip = bubble() + assert.ok(tip) + assert.equal(text(query(tip!, '.lc-skilld-name')), 'ghost') + assert.equal(queryAll(tip!, '.lc-skilld-chip').length, 0, 'no origin without a catalog entry') + assert.equal(queryAll(tip!, '.lc-skilld-desc').length, 0) + assert.equal(queryAll(tip!, '.lc-skilld-path').length, 0) + assert.ok(text(query(tip!, '.lc-skilld-foot')).includes('3 loads')) + await m.unmount() + }) + + test('the origin buckets localize; the long description caps; the pinned row’s hint flips to release', async () => { + const m = await mount(h(Skills, { + stats: ['a-project', 'b-user', 'c-bundled', 'd-bare'].map(name => stat(name, 1, NOW)), + catalog: new Map([ + ['a-project', { name: 'a-project', description: '', source: 'project-agents' }], + ['b-user', { name: 'b-user', description: '', source: 'user-dsh' }], + ['c-bundled', { name: 'c-bundled', description: '', source: 'bundled' }], + ['d-bare', { name: 'd-bare', description: '' }], + ]), + selected: 'a-project', + now: NOW, + })) + const rows = queryAll(m.container, 'button.lc-ov-skill') + const chipOf = async (row: Element): Promise => { + await hover(row) + const chip = bubble()?.querySelector('.lc-skilld-chip') + return chip === null || chip === undefined ? '' : (chip.textContent ?? '') + } + assert.equal(await chipOf(rows[0]), 'Project') + assert.equal(text(query(bubble()!, '.lc-skilld-hint')), 'Click again to release the filter', 'the pinned row’s hint') + assert.equal(await chipOf(rows[1]), 'User') + assert.equal(await chipOf(rows[2]), 'Built-in') + assert.equal(await chipOf(rows[3]), '', 'a source-less entry carries no chip') + await unhover(rows[3]) + // The unknown source displays raw; the long description caps at the ellipsis. + const m2 = await mount(h(Skills, { stats: [stat('custom-skill', 1, NOW)], catalog: CATALOG, now: NOW })) + await hover(query(m2.container, 'button.lc-ov-skill')) + assert.equal(text(query(bubble()!, '.lc-skilld-chip')), 'team-registry') + const desc = text(query(bubble()!, '.lc-skilld-desc')) + assert.equal(desc.length, 161, 'the 160 cap plus the ellipsis') + assert.ok(desc.endsWith('…')) + await m2.unmount() + await m.unmount() + }) + + test('the card measures against the row: centered and clamped inside the viewport, flipping below at the top edge', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], now: NOW })) + const row = query(m.container, 'button.lc-ov-skill') + // jsdom's zero rect flips below and clamps to the left margin. + await hover(row) + let style = bubble()?.getAttribute('style') ?? '' + assert.ok(style.includes('left: 8px'), style) + assert.ok(style.includes('top: 6px'), style) + await unhover(row) + // Mid-viewport: centered on the row, above it (jsdom bubbles measure 0×0). + mockRect(row, { left: 500, top: 200, width: 100, bottom: 220 }) + await hover(row) + style = bubble()?.getAttribute('style') ?? '' + assert.ok(style.includes('left: 550px'), style) + assert.ok(style.includes('top: 194px'), style) + await unhover(row) + // Off the right edge: clamped to the viewport margin (jsdom innerWidth 1024). + mockRect(row, { left: 2000, top: 200, width: 100, bottom: 220 }) + await hover(row) + style = bubble()?.getAttribute('style') ?? '' + assert.ok(style.includes('left: 1016px'), style) + await m.unmount() + }) + + test('a scroll or a resize retracts the card; focus and blur drive it too', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], now: NOW })) + const row = query(m.container, 'button.lc-ov-skill') + await hover(row) + assert.ok(bubble()) + await act(async () => { window.dispatchEvent(new Event('scroll')) }) + assert.equal(bubble(), null, 'any scroll retracts') + await hover(row) + assert.ok(bubble()) + await act(async () => { window.dispatchEvent(new Event('resize')) }) + assert.equal(bubble(), null, 'a resize retracts') + // React implements onFocus/onBlur over the bubbling focusin/focusout pair. + await act(async () => { row.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) }) + assert.ok(bubble(), 'keyboard focus raises the card') + await act(async () => { row.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) }) + assert.equal(bubble(), null, 'blur retracts') + await m.unmount() + }) + + test('a scope change under the hover drops the card’s content', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], now: NOW })) + await hover(query(m.container, 'button.lc-ov-skill')) + assert.ok(bubble()) + await m.update(h(Skills, { stats: [stat('other', 1, NOW)], now: NOW })) + assert.equal(bubble(), null, 'the hovered skill left the scope — nothing to say') + await m.unmount() + }) + + test('the pinned skill’s detail expands inline under its row: description first, then the fact grid with the copyable path', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + setSkillCopier(async (path) => { writes.push(path) }) + const m = await mount(h(Skills, { + stats: [stat('tdd', 2, NOW, 2), stat('grill-me', 1, NOW)], + selected: 'tdd', + catalog: CATALOG, + now: NOW, + })) + const row = query(m.container, 'button.lc-ov-skill') + const detail = row.nextElementSibling as HTMLElement + assert.ok(detail.classList.contains('lc-ov-skill-detail'), 'the detail hugs the pinned row, not the card bottom') + assert.equal(queryAll(detail, '.lc-skilld-head').length, 0, 'the inline seat needs no head — the row above names the skill') + assert.equal(text(query(detail, '.lc-skilld-desc')), 'Test-driven development. Write the test first.') + assert.ok(detail.querySelector('.lc-skilld-desc + .lc-skilld-grid') !== null, 'the description leads, the grid follows') + const grid = query(detail, '.lc-skilld-grid') + assert.deepEqual(queryAll(grid, '.lc-skilld-k').map(k => text(k)), ['Source', 'Usage', 'Last', 'Path']) + const values = queryAll(grid, '.lc-skilld-v') + assert.equal(text(values[0]), 'User') + assert.equal(text(values[1]), '2 loads · 2 sessions', 'the in-scope pin’s stats') + assert.ok(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(text(values[2])), 'the exact last load') + const pathBtn = query(grid, 'button.lc-skilld-pathv') + assert.equal(text(query(pathBtn, '.lc-skilld-pathv-text')), '/home/u/.agents/skills/tdd/SKILL.md') + assert.equal(pathBtn.getAttribute('data-lc-tip'), 'Click to copy the path') + await click(pathBtn) + await flush() + assert.deepEqual(writes, ['/home/u/.agents/skills/tdd/SKILL.md']) + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), 'Copied', 'the flash claims the landed copy') + // A second click mid-flash re-arms the timer — the flash holds. + await click(query(m.container, 'button.lc-skilld-pathv')) + await flush() + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), 'Copied') + await act(async () => { vi.advanceTimersByTime(1600) }) + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), '/home/u/.agents/skills/tdd/SKILL.md', 'the dwell passes, the path returns') + await m.unmount() + } finally { + vi.useRealTimers() + } + }) + + test('a failed copy never flashes', async () => { + setSkillCopier(async () => { throw new Error('denied') }) + const m = await mount(h(Skills, { + stats: [stat('tdd', 2, NOW)], + selected: 'tdd', + catalog: CATALOG, + now: NOW, + })) + await click(query(m.container, 'button.lc-skilld-pathv')) + await flush() + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), '/home/u/.agents/skills/tdd/SKILL.md') + await m.unmount() + }) + + test('an out-of-scope pin falls back to a detached block at the bottom, head included; moving the pin resets the flash', async () => { + vi.useFakeTimers() + try { + setSkillCopier(async () => {}) + const m = await mount(h(Skills, { stats: [], selected: 'tdd', catalog: CATALOG, now: NOW })) + assert.ok(text(m.container).includes('No skill loads in this range'), 'the scope stays empty') + const detail = query(m.container, '.lc-ov-skill-detail') + assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 0, 'no row to hug') + assert.equal(text(query(detail, '.lc-skilld-head .lc-skilld-name')), 'tdd', 'the detached seat carries the head') + assert.deepEqual(queryAll(detail, '.lc-skilld-k').map(k => text(k)), ['Source', 'Path'], 'no usage rows for an out-of-scope pin') + await click(query(m.container, 'button.lc-skilld-pathv')) + await flush() + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), 'Copied') + // The pin moves to the other cataloged skill: the flash resets, the new path shows. + await m.update(h(Skills, { stats: [], selected: 'custom-skill', catalog: CATALOG, now: NOW })) + assert.equal(text(query(m.container, '.lc-skilld-grid .lc-skilld-v')), 'team-registry', 'the unknown source displays raw in the grid') + assert.equal(text(query(m.container, '.lc-skilld-pathv-text')), '/opt/skills/custom.md') + // The pin releases: the block leaves (with its timer pending — the unmount-style cleanup runs here). + await m.update(h(Skills, { stats: [], selected: null, catalog: CATALOG, now: NOW })) + assert.equal(queryAll(m.container, '.lc-ov-skill-detail').length, 0) + await m.unmount() + } finally { + vi.useRealTimers() + } + }) + + test('a catalog-less pin with in-scope tallies still shows its usage rows; a ghost pin renders nothing', async () => { + const noCatalog = await mount(h(Skills, { stats: [stat('tdd', 4, NOW)], selected: 'tdd', catalog: null, now: NOW })) + const detail = query(noCatalog.container, '.lc-ov-skill-detail') + assert.equal(queryAll(detail, '.lc-skilld-head').length, 0) + assert.deepEqual(queryAll(detail, '.lc-skilld-k').map(k => text(k)), ['Usage', 'Last'], 'no source or path without the catalog') + assert.ok(text(queryAll(detail, '.lc-skilld-v')[0]).includes('4 loads')) + assert.equal(queryAll(detail, '.lc-skilld-pathv').length, 0) + await noCatalog.unmount() + const ghost = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], selected: 'ghost', catalog: CATALOG, now: NOW })) + assert.equal(queryAll(ghost.container, '.lc-ov-skill-detail').length, 0) + await ghost.unmount() + }) + + test('a hostile instant degrades the stats line, never the row', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 1, Number.NaN)], now: NOW })) + assert.equal(text(query(m.container, '.lc-ov-skill-last')), 'just now') + await hover(query(m.container, 'button.lc-ov-skill')) + const foot = text(query(bubble()!, '.lc-skilld-foot')) + assert.ok(foot.includes('1 loads'), foot) + assert.ok(!foot.includes('null') && !foot.includes('NaN'), foot) + await m.unmount() + }) + + test('the heatmap’s pinned day is named in the title', async () => { + const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], day: '2026-09-19', now: NOW })) + assert.ok(text(query(m.container, '.lc-card-title')).includes('2026-09-19')) + await m.unmount() + }) + + test('the render bound lists twenty rows and counts the overflow', async () => { + const stats = Array.from({ length: 25 }, (_, i) => stat(`skill-${i}`, i + 1, NOW - i * 1000)) + const m = await mount(h(Skills, { stats, now: NOW })) + assert.equal(queryAll(m.container, '.lc-ov-skill').length, 20) + assert.ok(text(m.container).includes('5 more skills')) + await m.unmount() + }) + + test('the sort toggle lists the two orderings, marks the active one, and relays picks', async () => { + const picks: string[] = [] + const m = await mount(h(Skills, { + stats: [stat('tdd', 2, NOW), stat('grill-me', 1, NOW)], + sort: 'recent', + onSort: (next) => { picks.push(next) }, + now: NOW, + })) + const group = query(m.container, '.lc-card-title .lc-gran') + const buttons = queryAll(group, 'button') + assert.deepEqual(buttons.map(b => text(b)), ['Loads', 'Recent']) + assert.equal(buttons[1].getAttribute('aria-pressed'), 'true', 'the picked ordering') + assert.ok(buttons[1].className.includes('lc-gran-on')) + await click(buttons[0]) + assert.deepEqual(picks, ['loads']) + await m.unmount() + // Fewer than two rows: nothing to order, the toggle stays out. + const one = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], onSort: () => {}, now: NOW })) + assert.equal(queryAll(one.container, '.lc-card-title .lc-gran').length, 0) + await one.unmount() + // No sort prop and no relay: the loads ordering is marked, a click only renders. + const bare = await mount(h(Skills, { stats: [stat('a', 1, NOW), stat('b', 2, NOW)], now: NOW })) + const bareButtons = queryAll(bare.container, '.lc-card-title .lc-gran button') + assert.equal(bareButtons[0].getAttribute('aria-pressed'), 'true') + await click(bareButtons[1]) + await bare.unmount() + }) + + test('zh locale renders the shipped strings', async () => { + const m = await mount(h(SkillsZh, { + stats: [stat('tdd', 2, NOW - 2 * 3_600_000)], + selected: 'tdd', + catalog: CATALOG, + now: NOW, + })) + assert.ok(text(m.container).includes('技能加载')) + assert.ok(text(m.container).includes('1 个技能 · 2 次加载'), 'the zh summary sub') + assert.ok(text(m.container).includes('2 小时前')) + const detail = query(m.container, '.lc-ov-skill-detail') + assert.deepEqual(queryAll(detail, '.lc-skilld-k').map(k => text(k)), ['来源', '用量', '最近', '路径']) + const values = queryAll(detail, '.lc-skilld-v') + assert.equal(text(values[0]), '用户') + assert.ok(text(values[1]).includes('2 次加载 · 1 个会话')) + assert.equal(query(m.container, '.lc-skilld-pathv').getAttribute('data-lc-tip'), '点击复制路径') + await hover(query(m.container, 'button.lc-ov-skill')) + assert.equal(text(query(bubble()!, '.lc-skilld-hint')), '再次点击取消筛选', 'the pinned row’s zh hint') + await m.unmount() + const empty = await mount(h(SkillsZh, { stats: [], now: NOW })) + assert.ok(text(empty.container).includes('暂无技能加载')) + await empty.unmount() + }) +}) diff --git a/tests/client/overview.spec.ts b/tests/client/overview.spec.ts index 1440833..97cad14 100644 --- a/tests/client/overview.spec.ts +++ b/tests/client/overview.spec.ts @@ -18,9 +18,11 @@ import { refreshSessions, relativeTime, requestActivityBackfill, + rowLoadedSkill, rowsOfSnapshot, sessionGroupsOf, sessionsSnapshotOf, + skillLoadsOf, sortRows, timingSumOf, tokenPartsOf, @@ -31,6 +33,7 @@ import { type OverviewRow, } from '../../src/client/overview' import type { ClientCtx } from '../../src/client/services' +import { dayKeyOf } from '../../src/shared/days' import type { ContextActivity, ContextTimeline, SessionCostUsage } from '../../src/shared/types' import { mergeCostUsage, priceIndexOf } from '../../src/client/cost' @@ -682,8 +685,30 @@ describe('rowsOfSnapshot: the family fold', () => { assert.equal(billedOf(rows[0]), 500, 'COST + COST2 — the cycle’s revisit adds nothing twice') }) - test('hostile descendant projections degrade to null members, never a throw', () => { + test('the daily ledgers’ skill tables merge name by name (loads sum, last takes the max)', () => { const rows = rowsOfSnapshot(snapshotOf([ + ['root', { + displayTitle: 'root', + projectionValues: { + contextActivity: { days: { '2026-09-16': { tokens: 5, requests: 1, skills: { tdd: { n: 2, last: 100 }, solo: { n: 1, last: 90 } } } } }, + }, + }], + ['kid', { + origin: 'subagent', parentId: 'root', + projectionValues: { + contextActivity: { days: { '2026-09-16': { tokens: 7, requests: 2, skills: { tdd: { n: 3, last: 200 }, fresh: { n: 1, last: 300 } } } } }, + }, + }], + ])) + assert.ok(rows !== null) + assert.deepEqual(rows[0].activity?.days['2026-09-16'].skills, { + tdd: { n: 5, last: 200 }, + solo: { n: 1, last: 90 }, + fresh: { n: 1, last: 300 }, + }) + }) + + test('hostile descendant projections degrade to null members, never a throw', () => { const rows = rowsOfSnapshot(snapshotOf([ ['root', { displayTitle: 'root', projectionValues: { contextTimeline: billedHead(1, COST) } }], ['kid', { origin: 'subagent', parentId: 'root', projectionValues: { contextTimeline: 7, contextActivity: 'x' } }], ['junk', 5 as unknown as Record], @@ -696,6 +721,140 @@ describe('rowsOfSnapshot: the family fold', () => { }) }) +describe('skillLoadsOf', () => { + const NOW = Date.UTC(2026, 8, 20, 12) + /** The local day key `back` days before NOW — TZ-independent. */ + const day = (back: number): string => { + const key = dayKeyOf(NOW - back * 86_400_000) + assert.ok(key !== null) + return key + } + /** A row whose family ledger carries the given day → skill table entries. */ + function rowWith(id: string, days: ContextActivity['days']): OverviewRow { + return rowOf({ id, activity: { days } }) + } + + test('the range’s day floor scopes the aggregation; every member sums and the last load maximizes', () => { + const rows = [ + rowWith('a', { + [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 2, last: NOW - 1000 } } }, + [day(10)]: { tokens: 1, requests: 1, skills: { tdd: { n: 5, last: NOW - 900_000 }, stale: { n: 1, last: NOW - 900_000 } } }, + }), + rowWith('b', { + [day(1)]: { tokens: 1, requests: 1, skills: { tdd: { n: 3, last: NOW - 2000 }, grill: { n: 1, last: NOW - 500 } } }, + }), + ] + assert.deepEqual(skillLoadsOf(rows, { range: '24h', day: null }, NOW), [ + { name: 'tdd', loads: 5, sessions: 2, last: NOW - 1000 }, + { name: 'grill', loads: 1, sessions: 1, last: NOW - 500 }, + ], 'the 10-days-ago entries fall below the floor') + assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW), [ + { name: 'tdd', loads: 10, sessions: 2, last: NOW - 1000 }, + { name: 'grill', loads: 1, sessions: 1, last: NOW - 500 }, + { name: 'stale', loads: 1, sessions: 1, last: NOW - 900_000 }, + ]) + }) + + test('the loader tally counts a row once however many of its days carry the name', () => { + const rows = [ + rowWith('a', { + [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 1, last: NOW - 3000 } } }, + [day(1)]: { tokens: 1, requests: 1, skills: { tdd: { n: 1, last: NOW - 2000 } } }, + }), + rowWith('b', { [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 1, last: NOW - 500 } } } }), + ] + assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW), [ + { name: 'tdd', loads: 3, sessions: 2, last: NOW - 500 }, + ]) + }) + + test('the sort keys reorder; ties fall to the next key, then the name', () => { + const rows = [ + rowWith('a', { + [day(0)]: { + tokens: 1, + requests: 1, + skills: { + alpha: { n: 3, last: NOW - 3000 }, // 3 loads, 1 loader, stalest + beta: { n: 1, last: NOW - 1000 }, // 2 loads, 2 loaders + gamma: { n: 2, last: NOW - 500 }, // 2 loads, 1 loader, freshest + delta: { n: 1, last: NOW - 700 }, // delta and epsilon agree on every key — the name decides + epsilon: { n: 1, last: NOW - 700 }, + }, + }, + }), + rowWith('b', { [day(0)]: { tokens: 1, requests: 1, skills: { beta: { n: 1, last: NOW - 2000 } } } }), + ] + const names = (sort?: 'loads' | 'recent'): string[] => + skillLoadsOf(rows, { range: 'all', day: null, sort }, NOW).map(s => s.name) + assert.deepEqual(names(), ['alpha', 'gamma', 'beta', 'delta', 'epsilon'], 'loads (the default): loads desc, then recent — the gamma/beta tie breaks on freshness') + assert.deepEqual(names('loads'), ['alpha', 'gamma', 'beta', 'delta', 'epsilon'], 'the explicit key matches the omission') + assert.deepEqual(names('recent'), ['gamma', 'delta', 'epsilon', 'beta', 'alpha'], 'recent: the freshest first') + }) + + test('the heatmap’s pinned day scopes to that day exactly', () => { + const rows = [ + rowWith('a', { + [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 2, last: NOW - 1000 } } }, + [day(1)]: { tokens: 1, requests: 1, skills: { grill: { n: 4, last: NOW - 80_000 } } }, + }), + ] + assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: day(1) }, NOW), [ + { name: 'grill', loads: 4, sessions: 1, last: NOW - 80_000 }, + ]) + }) + + test('ties break recent-first, then by name; rows and days without skills skip', () => { + const rows = [ + rowOf({ id: 'bare' }), // no ledger at all + rowWith('empty-day', { [day(0)]: { tokens: 2, requests: 1 } }), + rowWith('a', { + [day(0)]: { + tokens: 1, + requests: 1, + skills: { + beta: { n: 2, last: NOW - 1000 }, + alpha: { n: 2, last: NOW - 1000 }, + gamma: { n: 2, last: NOW - 1000 }, + delta: { n: 5, last: NOW - 9000 }, + }, + }, + }), + ] + assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW).map(s => s.name), ['delta', 'alpha', 'beta', 'gamma']) + }) +}) + +describe('rowLoadedSkill', () => { + const NOW = Date.UTC(2026, 8, 20, 12) + /** The local day key `back` days before NOW — TZ-independent. */ + const day = (back: number): string => { + const key = dayKeyOf(NOW - back * 86_400_000) + assert.ok(key !== null) + return key + } + + test('the scope’s day predicate decides — the range floor, the pinned day, and the skill-less days', () => { + const row = rowOf({ + id: 'a', + activity: { + days: { + [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 2, last: NOW - 1000 } } }, + [day(1)]: { tokens: 1, requests: 1 }, + [day(10)]: { tokens: 1, requests: 1, skills: { grill: { n: 1, last: NOW - 900_000 } } }, + }, + }, + }) + assert.equal(rowLoadedSkill(row, 'tdd', { range: '24h', day: null }, NOW), true, 'loaded today — inside the 24h floor') + assert.equal(rowLoadedSkill(row, 'grill', { range: '24h', day: null }, NOW), false, 'the 10-days-ago load falls below the floor') + assert.equal(rowLoadedSkill(row, 'grill', { range: 'all', day: null }, NOW), true) + assert.equal(rowLoadedSkill(row, 'tdd', { range: 'all', day: day(1) }, NOW), false, 'the pinned day admits only itself') + assert.equal(rowLoadedSkill(row, 'grill', { range: 'all', day: day(10) }, NOW), true) + assert.equal(rowLoadedSkill(row, 'never', { range: 'all', day: null }, NOW), false, 'a name no day carries') + assert.equal(rowLoadedSkill(rowOf({ id: 'bare' }), 'tdd', { range: 'all', day: null }, NOW), false, 'no ledger at all') + }) +}) + describe('workspacesSnapshotOf', () => { test('a non-function or throwing seat reads null; the raw snapshot passes through', () => { assert.equal(workspacesSnapshotOf({}), null) diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index b2a2240..9762c1d 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -947,4 +947,36 @@ describe('activityOf', () => { days: { '2026-09-16': { tokens: 15, requests: 1 }, '2026-09-15': { tokens: 3, requests: 1 } }, }) }) + + test('a well-formed skill table rides the pass-through; malformed tallies drop per name', () => { + const skills = { tdd: { n: 2, last: 1_758_000_000_000 } } + const wire = { days: { '2026-09-16': { tokens: 15, requests: 1, skills } } } + assert.ok(activityOf(wire) === (wire as never)) + const out = activityOf({ + days: { + '2026-09-16': { + tokens: 15, + requests: 1, + skills: { + tdd: { n: 1, last: 5 }, + 'bad-count': { n: 'x', last: 5 }, + 'bad-last': { n: 1, last: Number.NaN }, + 'neg-count': { n: -1, last: 5 }, + 'not-record': null, + }, + }, + '2026-09-17': { tokens: 1, requests: 1, skills: 'x' }, + '2026-09-18': { tokens: 1, requests: 1, skills: [] }, + '2026-09-19': { tokens: 1, requests: 1, skills: {} }, + }, + }) + assert.deepEqual(out, { + days: { + '2026-09-16': { tokens: 15, requests: 1, skills: { tdd: { n: 1, last: 5 } } }, + '2026-09-17': { tokens: 1, requests: 1 }, + '2026-09-18': { tokens: 1, requests: 1 }, + '2026-09-19': { tokens: 1, requests: 1 }, + }, + }, 'non-record and empty-after-scrubbing tables drop whole; the days survive') + }) }) diff --git a/tests/client/skills.spec.ts b/tests/client/skills.spec.ts new file mode 100644 index 0000000..0547d6f --- /dev/null +++ b/tests/client/skills.spec.ts @@ -0,0 +1,123 @@ +// The client skill catalog (src/client/skills.ts): the payload narrowing +// (per-entry proofs, individual drops), the route read's never-reject +// contract (transport, status, envelope, and empty answers all read null), +// the cwd body's shape, and the clipboard seam behind the path footer. + +import assert from 'node:assert/strict' +import { afterEach, describe, test, vi } from 'vitest' +import { copySkillPath, readSkillCatalog, setSkillCopier, skillInfoListOf } from '../../src/client/skills' + +afterEach(() => { + setSkillCopier(null) + vi.unstubAllGlobals() +}) + +describe('skillInfoListOf', () => { + test('absent or malformed payloads read null', () => { + assert.equal(skillInfoListOf(null), null) + assert.equal(skillInfoListOf('x'), null) + assert.equal(skillInfoListOf([]), null) + assert.equal(skillInfoListOf({}), null) + assert.equal(skillInfoListOf({ skills: 'nope' }), null) + }) + + test('entries failing the name proof drop individually; the proved fields ride alone', () => { + assert.deepEqual(skillInfoListOf({ + skills: [ + null, + 'row', + [], + { description: 'no name' }, + { name: '' }, + { name: 42 }, + { name: 'bare' }, + { name: 'full', description: 'd', path: '/p/SKILL.md', source: 'bundled' }, + { name: 'odd', description: 7, path: '', source: '' }, + ], + }), [ + { name: 'bare', description: '' }, + { name: 'full', description: 'd', path: '/p/SKILL.md', source: 'bundled' }, + { name: 'odd', description: '' }, + ]) + }) +}) + +describe('readSkillCatalog', () => { + function stubFetch(mode: 'ok' | 'reject' | 'status' | 'badjson', body?: unknown): { urls: string[]; bodies: unknown[] } { + const urls: string[] = [] + const bodies: unknown[] = [] + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + urls.push(String(url)) + bodies.push(init?.body === undefined ? undefined : JSON.parse(String(init.body))) + if (mode === 'reject') throw new Error('down') + if (mode === 'status') return { ok: false, json: async () => ({}) } as unknown as Response + if (mode === 'badjson') return { ok: true, json: () => Promise.reject(new Error('not json')) } as unknown as Response + return { ok: true, json: async () => body } as unknown as Response + }) + return { urls, bodies } + } + + test('a served catalog narrows into the name-keyed map; the cwd and session id ride the body', async () => { + const { urls, bodies } = stubFetch('ok', { + ok: true, + value: { + skills: [ + { name: 'tdd', description: 'Test-driven', path: '/p/tdd/SKILL.md', source: 'user-agents' }, + { name: 'ask-matt', description: 'A router' }, + ], + }, + }) + const catalog = await readSkillCatalog('/repo/alpha', 's1') + assert.deepEqual(urls, ['/api/dsh-context/skills']) + assert.deepEqual(bodies, [{ cwd: '/repo/alpha', sessionId: 's1' }]) + assert.equal(catalog?.size, 2) + assert.deepEqual(catalog?.get('tdd'), { name: 'tdd', description: 'Test-driven', path: '/p/tdd/SKILL.md', source: 'user-agents' }) + }) + + test('a context-less open posts an empty body', async () => { + const { bodies } = stubFetch('ok', { ok: true, value: { skills: [{ name: 'tdd' }] } }) + await readSkillCatalog(undefined, undefined) + assert.deepEqual(bodies, [{}]) + }) + + test('every failure or empty answer reads null, never a rejection', async () => { + stubFetch('reject') + assert.equal(await readSkillCatalog(undefined, undefined), null) + stubFetch('status') + assert.equal(await readSkillCatalog(undefined, undefined), null) + stubFetch('badjson') + assert.equal(await readSkillCatalog(undefined, undefined), null) + stubFetch('ok', {}) + assert.equal(await readSkillCatalog(undefined, undefined), null, 'no ok flag') + stubFetch('ok', { ok: false, value: { skills: [{ name: 'tdd' }] } }) + assert.equal(await readSkillCatalog(undefined, undefined), null) + stubFetch('ok', { ok: true, value: null }) + assert.equal(await readSkillCatalog(undefined, undefined), null) + stubFetch('ok', { ok: true, value: { skills: [] } }) + assert.equal(await readSkillCatalog(undefined, undefined), null, 'an empty catalog is no enrichment') + stubFetch('ok', { ok: true, value: { skills: 'x' } }) + assert.equal(await readSkillCatalog(undefined, undefined), null) + }) +}) + +describe('copySkillPath', () => { + test('the seam’s write resolves true; a throwing write resolves false', async () => { + const seen: string[] = [] + setSkillCopier(async (text) => { seen.push(text) }) + assert.equal(await copySkillPath('/p/tdd/SKILL.md'), true) + assert.deepEqual(seen, ['/p/tdd/SKILL.md']) + setSkillCopier(async () => { throw new Error('denied') }) + assert.equal(await copySkillPath('/p/tdd/SKILL.md'), false) + }) + + test('the default write rides the platform clipboard; an absent API fails the copy', async () => { + const written: string[] = [] + vi.stubGlobal('navigator', { clipboard: { writeText: async (text: string) => { written.push(text) } } }) + assert.equal(await copySkillPath('/p/a/SKILL.md'), true) + assert.deepEqual(written, ['/p/a/SKILL.md']) + vi.stubGlobal('navigator', {}) + assert.equal(await copySkillPath('/p/a/SKILL.md'), false) + vi.stubGlobal('navigator', undefined) + assert.equal(await copySkillPath('/p/a/SKILL.md'), false) + }) +})