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:
1 parent
b424e59a5b
commit
48302d1962
12 files changed
+1593
-14
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user