feat(overview): the activity column's skill-load card

The Context Insights page's second insight card lists every skill loaded
within the page's scope (the range floor, or the heatmap's pinned day):
a color-dotted row per name with its load tally, a proportional share
bar scaled to the heaviest row, and the last load's relative time,
ordered by the title's loads / last-used toggle. A row click pins the
session list to that skill's loaders; the pinned row wraps with its
detail into one ringed unit — the description first, then a fact grid
(origin, usage, exact last load, the full path as a click-to-copy
value). A structured hover card answers the same in a floating register.
The catalog read joins description, origin, and path onto the tallies by
name and degrades to bare tallies when the route cannot serve.
This commit is contained in:
bowenliang123 committed 2026-10-09 00:57:42 +08:00
1 parent b424e59a5b
commit 48302d1962
12 files changed
+1593 -14

No files matched your search

+58 -6
View File
@@ -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<string | null>(null)
const [query, setQuery] = useState('')
const [group, setGroup] = useState<string | null>(null)
const [skill, setSkill] = useState<string | null>(null)
const [skillSort, setSkillSort] = useState<SkillSort>('loads')
const [sort, setSort] = useState<OverviewSort>('recent')
const [metric, setMetric] = useState<HeatMetric>('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<ReadonlyMap<string, SkillInfo> | 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
</div>
<Heatmap days={days} metric={metric} selected={day} onSelect={setDay} today={todayKey()} />
</div>
{/* 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. */}
<OverviewSkills
stats={skillLoadsOf(ranged, { range, day, sort: skillSort }, now)}
day={day}
selected={skill}
onSelect={setSkill}
sort={skillSort}
onSort={setSkillSort}
catalog={catalog}
now={now}
/>
</div>
<div className="lc-ov-right">
@@ -264,6 +311,11 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
{t('ov.list.dayFilter', { day })} ×
</button>
)}
{skill !== null && (
<button type="button" className="lc-ov-day-chip" title={t('ov.list.skillClear')} onClick={() => { setSkill(null) }}>
{t('node.skillTag', { name: skill })} ×
</button>
)}
<input
className="lc-ov-search"
type="search"
+389
View File
@@ -0,0 +1,389 @@
/**
* The Context Insights page's skill card — the activity column's second card,
* under the heatmap. One row per skill loaded within the page's scope (the
* range selector's day floor, or the heatmap's pinned day exactly — the
* aggregation is overview.ts's skillLoadsOf over the rows' family ledgers),
* in the picked order (the title's segmented toggle: loads / last used): a
* name-hashed color dot, the load tally, a
* proportional hairline bar (share of the heaviest row's tally), and the last
* load's relative time. A row click PINs the session list to the sessions
* that loaded the skill (the column's drill-down, beside the heatmap's day
* pin — click again to release).
*
* The card also answers what a skill IS, in two registers:
* - a HOVER card (portaled like the page's hoverTip, but structured — the
* page's plain-text bubble cannot hold a hierarchy): name + origin chip,
* the registry description, the on-disk path, and a footer split between
* the load stats and the click action;
* - the PINNED skill's detail, expanded inline right under its row (a long
* list would bury a bottom block): the description first, then a two-column
* fact grid (origin / usage / last load / path as a click-to-copy value).
* A pin whose row is out of scope falls back to a detached block at the
* card's bottom, head row included.
*
* The catalog (client/skills.ts, off the plugin's `/api/dsh-context/skills`
* route) is best-effort end to end — an absent route leaves both registers
* rendering the tallies unenriched.
*/
import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
import { dayKeyOf } from '../../shared/days'
import type { SkillInfo } from '../../shared/types'
import { relativeTime, type SkillLoadStat, type SkillSort } from '../overview'
import { copySkillPath } from '../skills'
import { IconCheck, IconCopy } from '../primitives'
import type { ViewKit } from '../viewkit'
/**
* The render bound on listed skills — a hostile corpus can coin a name per
* injection; the overflow line keeps the tally honest without a
* thousand-row card.
*/
const MAX_ROWS = 20
/** The description's display bound — a registry description can run paragraphs. */
const MAX_DESC = 160
/** The copied flash's dwell on the path line. */
const COPIED_MS = 1500
/** Anchor-to-bubble distance and the viewport margin the hover card clamps inside, in px. */
const TIP_GAP = 6
const TIP_MARGIN = 8
/** The hover card's identity for the row's `aria-describedby` (one live card at a time). */
const TIP_ID = 'lc-skilltip-bubble'
/** The per-skill dot/bar colors: the composition chart's own palette, picked by name hash so a skill keeps its color across scopes. */
const SKILL_COLORS = [
'var(--color-blue-500)',
'var(--color-green-500)',
'var(--color-purple-500)',
'var(--color-orange-500)',
'var(--color-teal-500)',
'var(--color-pink-500)',
'var(--color-indigo-500)',
'var(--color-amber-500)',
] as const
/** The sort toggle's options, in display order; the first is the default. */
const SKILL_SORTS: readonly SkillSort[] = ['loads', 'recent']
/** The skill's stable palette color (djb2 over the name — small, deterministic, sufficient for a distinguishing hint). */
export function skillColorOf(name: string): string {
let hash = 5381
for (let i = 0; i < name.length; i++) hash = ((hash << 5) + hash + name.charCodeAt(i)) >>> 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 (
<div className="lc-skilld-head">
<i className="lc-ov-skill-dot" style={{ background: skillColorOf(props.name) }} />
<span className="lc-skilld-name">{props.name}</span>
{props.origin !== '' && <span className="lc-skilld-chip">{props.origin}</span>}
</div>
)
}
/** 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<string, SkillInfo> | 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<HoverRow | null>(null)
const [spot, setSpot] = useState<{ forRow: HoverRow; left: number; top: number } | null>(null)
const bubbleRef = useRef<HTMLDivElement | null>(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<ReturnType<typeof setTimeout> | 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<void> => {
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 : (
<div className="lc-ov-skill-detail">
{pinnedStat === undefined && <DetailHead name={detail.name} origin="" />}
{detail.description !== '' && <p className="lc-skilld-desc">{detail.description}</p>}
<div className="lc-skilld-grid">
{detail.origin !== '' && (
<Fragment>
<span className="lc-skilld-k">{t('ov.skills.k.source')}</span>
<span className="lc-skilld-v">{detail.origin}</span>
</Fragment>
)}
{pinnedStat !== undefined && (
<Fragment>
<span className="lc-skilld-k">{t('ov.skills.k.usage')}</span>
<span className="lc-skilld-v">{usageLineOf(kit, pinnedStat)}</span>
<span className="lc-skilld-k">{t('ov.skills.k.last')}</span>
<span className="lc-skilld-v">{lastLineOf(kit, pinnedStat)}</span>
</Fragment>
)}
{detailPath !== undefined && (
<Fragment>
<span className="lc-skilld-k">{t('ov.skills.k.path')}</span>
<button
type="button"
className="lc-skilld-v lc-skilld-pathv"
data-lc-tip={t('ov.skills.copyPath')}
onClick={() => { void copyPath(detailPath) }}
>
<span className="lc-skilld-pathv-text">{copied ? t('ov.skills.copied') : detailPath}</span>
{copied ? <IconCheck size={12} /> : <IconCopy size={12} />}
</button>
</Fragment>
)}
</div>
</div>
)
// 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(
<div
ref={bubbleRef}
id={TIP_ID}
role="tooltip"
className="lc-tip lc-skilltip"
style={shown === null
? { left: 0, top: 0, visibility: 'hidden' }
: { left: shown.left, top: shown.top, opacity: 1 }}
>
<DetailHead name={hoverStat.name} origin={origin} />
{description !== '' && <p className="lc-skilld-desc">{description}</p>}
{info?.path !== undefined && <div className="lc-skilld-path">{info.path}</div>}
<div className="lc-skilld-foot">
<span>{statsLineOf(kit, hoverStat)}</span>
<span className="lc-skilld-hint">{pinned ? t('ov.skills.clickRelease') : t('ov.skills.clickFilter')}</span>
</div>
</div>,
document.body,
)
})()
const sort = props.sort ?? 'loads'
return (
<div className="lc-card lc-ov-skills-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('stats.skills')}</span>
{props.day !== null && props.day !== undefined && <span className="lc-card-sub">{props.day}</span>}
{/* 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 && (
<div className="lc-gran" role="group" aria-label={t('ov.skills.sortLabel')}>
{SKILL_SORTS.map(so => (
<button
key={so}
type="button"
className={'lc-gran-btn' + (sort === so ? ' lc-gran-on' : '')}
aria-pressed={sort === so}
onClick={() => { props.onSort?.(so) }}
>{t('ov.skills.sort.' + so)}</button>
))}
</div>
)}
</div>
{/* The scope's totals live under the title, clear of the toggle. */}
{stats.length > 0 && (
<div className="lc-card-sub lc-ov-skills-sub">
{t('ov.skills.summary', { skills: fmt(stats.length), loads: fmt(total) })}
</div>
)}
{stats.length === 0 ? (
<div className="lc-empty">{t('ov.skills.empty')}</div>
) : (
<div className="lc-ov-skills" role="group" aria-label={t('stats.skills')}>
{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 = <i className="lc-ov-skill-bar" style={{ width: `calc(${share}% - ${Math.round(share * 12) / 100}px)`, background: color }} />
const rowButton = (
<button
type="button"
className="lc-ov-skill"
aria-pressed={pinned}
aria-describedby={hoverRow?.name === s.name ? TIP_ID : undefined}
onMouseEnter={(ev) => {
const r = ev.currentTarget.getBoundingClientRect()
setHoverRow({ name: s.name, rect: { left: r.left, top: r.top, width: r.width, bottom: r.bottom } })
}}
onMouseLeave={() => { setHoverRow(null) }}
onFocus={(ev) => {
const r = ev.currentTarget.getBoundingClientRect()
setHoverRow({ name: s.name, rect: { left: r.left, top: r.top, width: r.width, bottom: r.bottom } })
}}
onBlur={() => { setHoverRow(null) }}
onClick={() => { props.onSelect?.(pinned ? null : s.name) }}
>
<i className="lc-ov-skill-dot" style={{ background: color }} />
<span className="lc-ov-skill-name">{s.name}</span>
<span className="lc-ov-skill-count">×{fmt(s.loads)}</span>
<span className="lc-ov-skill-last">{relativeTime(t, s.last, props.now)}</span>
{bar}
</button>
)
// 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
? <div key={s.name} className="lc-ov-skill-unit">{rowButton}{detailBlock}</div>
: <Fragment key={s.name}>{rowButton}</Fragment>
})}
{stats.length > MAX_ROWS && (
<div className="lc-ov-skill-more">{t('ov.skills.more', { n: stats.length - MAX_ROWS })}</div>
)}
</div>
)}
{/* 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}
</div>
)
}
}
+40
View File
@@ -395,6 +395,25 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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',
+118 -4
View File
@@ -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<ActivityDay['skills']>): NonNullable<ActivityDay['skills']> {
// Widened honestly: a Record index read can miss at runtime.
const out: Record<string, { n: number; last: number } | undefined> = { ...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<ActivityDay['skills']>
}
// ---- range / filter / sort -------------------------------------------------
export type OverviewRange = '24h' | '7d' | '30d' | 'all'
@@ -406,6 +427,99 @@ export function pageOf<T>(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<string, SkillLoadStat>()
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<string>()
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
+33 -1
View File
@@ -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<string, { n: number; last: number }> = {}
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). */
+105
View File
@@ -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<ReadonlyMap<string, SkillInfo> | 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<void>
/** 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<void>).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<boolean> {
try {
await copier(path)
return true
} catch {
return false
}
}
+68 -1
View File
@@ -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 <body>, 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); }
+49 -1
View File
@@ -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<string, Record<string, unknown>> } }).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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(m.container, 'button.lc-ov-skill'))
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1)
await click(query<HTMLButtonElement>(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)
@@ -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<string, SkillInfo> = 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<HTMLButtonElement>(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<HTMLElement>(rows[0], '.lc-ov-skill-dot').getAttribute('style')?.includes(skillColorOf('tdd')))
assert.ok(query<HTMLElement>(rows[0], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(100% - 12px)'))
assert.ok(query<HTMLElement>(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<HTMLElement>(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<HTMLElement>(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<HTMLButtonElement>(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 <body>')
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<string, SkillInfo>([
['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<string> => {
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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(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()
})
})
+160 -1
View File
@@ -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<string, unknown>],
@@ -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)
+32
View File
@@ -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')
})
})
+123
View File
@@ -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)
})
})