feat(client): add a session coverage picker to Context Insights, defaulting to all
Archived sessions fold back into every figure the page derives off the session list: rowsOfSnapshot now tags archived rows instead of dropping them, and one coveredRowsOf pass scopes the KPI band, charts, heatmap, skills, and the session grid together. A "Filter sessions" select next to the list's search box offers dsh's own archived-filter set — All (show archived) by default, Hide archived, Archived only. The Active Sessions card follows the picked coverage (Sessions incl. the window's archived count under All; Active/Archived Sessions over the full-list total under the narrow scopes), archived cards wear an Archived tag and refuse to open like dsh does, and kpisOf drops its now-unused listed param.
This commit is contained in:
1 parent
ca5a3e8d3d
commit
7ae8de869d
11 files changed
+156
-33
No files matched your search
@@ -36,12 +36,14 @@ export function makeOverviewCard(kit: ViewKit): (props: OverviewCardProps) => Re
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-ov-session' + (row.current ? ' lc-ov-session-current' : '')}
|
||||
onClick={() => { props.onOpen(row.id) }}
|
||||
aria-disabled={row.archived || undefined}
|
||||
onClick={() => { if (!row.archived) props.onOpen(row.id) }}
|
||||
>
|
||||
<span className="lc-ov-session-head">
|
||||
{row.running && <span className="lc-ov-running" title={t('ov.running')} />}
|
||||
<span className="lc-ov-session-title" title={row.title}>{row.title}</span>
|
||||
{row.current && <span className="lc-ov-current">{t('ov.current')}</span>}
|
||||
{row.archived && <span className="lc-ov-archived" title={t('ov.card.archivedTip')}>{t('ov.card.archived')}</span>}
|
||||
<span className="lc-ov-session-times">
|
||||
<span className="lc-ov-session-time">{relativeTime(t, row.updatedAt, props.now)}</span>
|
||||
{created !== undefined && <span className="lc-ov-session-created">{created}</span>}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* <body>, because that container would otherwise capture their fixed positioning.
|
||||
*
|
||||
* The cluster holds ONE scope: a picked calendar range replaces the rolling preset and clicking a preset hands it
|
||||
* back, so they never both claim it, and everything the presets scope reads that one resolved window. Below the KPI
|
||||
* back, so they never both claim it, and everything the presets scope reads that one resolved window. The coverage
|
||||
* picker sits at the sessions feed's head and defaults to All: archived sessions fold into the page's every figure
|
||||
* unless the picker narrows them, and the Active Sessions card's label and sub-line follow the picked coverage.
|
||||
* Below the KPI
|
||||
* band and the usage chart, the heatmap pins both its column mates to a picked day, and a skill-card row click
|
||||
* narrows the session column to that skill's loaders within the same scope. A session card jumps through the
|
||||
* harness's own `openSessionVia`, whose navigation returns the center column to the conversation.
|
||||
@@ -19,11 +22,11 @@ import { estimateSessionCost, formatCost, type CostCurrency, type ModelBook } fr
|
||||
import { fmt } from '../format'
|
||||
import { useModelPrices } from '../modelPrices'
|
||||
import {
|
||||
aggregateDays, dayRangeWindow, filterRows, groupCountsOf, inGroup, kpisOf,
|
||||
aggregateDays, coveredRowsOf, dayRangeWindow, filterRows, groupCountsOf, inGroup, kpisOf,
|
||||
pageOf, rangeWindowOf, refreshSessions, requestActivityBackfill, rowsOfSnapshot,
|
||||
rowLoadedSkill, sessionGroupsOf, sessionsSnapshotOf, skillLoadsOf, sortRows,
|
||||
UNGROUPED_KEY, workspacesSnapshotOf,
|
||||
type DayRange, type OverviewRange, type OverviewRow, type OverviewSort, type SkillSort,
|
||||
type DayRange, type OverviewCoverage, type OverviewRange, type OverviewRow, type OverviewSort, type SkillSort,
|
||||
} from '../overview'
|
||||
import { openSessionVia, type ClientCtx } from '../services'
|
||||
import { readSkillCatalog } from '../skills'
|
||||
@@ -52,6 +55,8 @@ export interface OverviewPanelProps {
|
||||
|
||||
const RANGES: readonly OverviewRange[] = ['today', '7d', '30d', 'all']
|
||||
const SORTS: readonly OverviewSort[] = ['recent', 'tokens', 'context']
|
||||
/** The coverage scopes the sessions feed serves, in menu order. */
|
||||
const COVERAGE: readonly OverviewCoverage[] = ['all', 'active', 'archived']
|
||||
/** The heatmap's depth metrics, in toggle order (steps is the default). */
|
||||
const METRICS: readonly HeatMetric[] = ['sessions', 'steps']
|
||||
|
||||
@@ -85,6 +90,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const wsSnapshot = workspacesSnapshotOf(props)
|
||||
const [preset, setPreset] = useState<OverviewRange>('30d')
|
||||
const [custom, setCustom] = useState<DayRange | null>(null)
|
||||
const [coverage, setCoverage] = useState<OverviewCoverage>('all')
|
||||
const [day, setDay] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [group, setGroup] = useState<string | null>(null)
|
||||
@@ -122,11 +128,12 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
}, [])
|
||||
|
||||
// Any filter change re-anchors the pager at the first page.
|
||||
useEffect(() => { setPage(0) }, [preset, custom, day, query, group, skill, sort])
|
||||
useEffect(() => { setPage(0) }, [preset, custom, coverage, day, query, group, skill, sort])
|
||||
|
||||
const currency = activeCurrency()
|
||||
const now = Date.now()
|
||||
const allRows = rows ?? []
|
||||
const covered = coveredRowsOf(allRows, coverage)
|
||||
// The scope window every figure below reads: the calendar's own picked
|
||||
// range while one is set (a malformed key keeps the preset, never an unprovable window), else the preset's rolling floor.
|
||||
const scope = (custom !== null ? dayRangeWindow(custom) : null) ?? rangeWindowOf(preset, now)
|
||||
@@ -137,8 +144,8 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const spanTo = scope.start === null ? null : dayKeyOf(scope.end ?? now)
|
||||
const span = spanFrom !== null && spanTo !== null ? { from: spanFrom, to: spanTo } : null
|
||||
// The scope scopes the KPI band, the composition donut, and the grid;
|
||||
// the heatmap keeps its own fixed window over the whole list.
|
||||
const ranged = filterRows(allRows, { scope, day: null, query: '' })
|
||||
// the heatmap keeps its own fixed window over the coverage-selected list.
|
||||
const ranged = filterRows(covered, { scope, day: null, query: '' })
|
||||
// The group chips count the day/query-scoped rows BEFORE the group filter
|
||||
// applies, so selecting a chip never collapses the row itself.
|
||||
const scoped = filterRows(ranged, { scope, day, query })
|
||||
@@ -156,13 +163,19 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
sort,
|
||||
)
|
||||
const paged = pageOf(visible, page)
|
||||
const kpi = kpisOf(ranged, allRows.length, book, currency)
|
||||
const days = aggregateDays(allRows, book, currency)
|
||||
const kpi = kpisOf(ranged, book, currency)
|
||||
const days = aggregateDays(covered, book, currency)
|
||||
const openOne = (id: string): void => {
|
||||
// The view owner's verb navigates the center column back to the
|
||||
// conversation itself (selectPanel(null)), so the page needs no close.
|
||||
openSessionVia(ctx, id)
|
||||
}
|
||||
// The sessions card mirrors its coverage: All prices in the window's
|
||||
// archived share; the narrow scopes name what the full list holds.
|
||||
const sessionsLabel = coverage === 'archived' ? t('ov.kpi.archived') : coverage === 'active' ? t('ov.kpi.sessions') : t('ov.kpi.allSessions')
|
||||
const sessionsSub = coverage === 'all'
|
||||
? t('ov.kpi.inclArchived', { n: ranged.filter(row => row.archived).length })
|
||||
: t('ov.kpi.ofTotal', { n: allRows.length })
|
||||
|
||||
return (
|
||||
<section className="lc-ov-page" aria-label={t('ov.title')} {...zoneProps}>
|
||||
@@ -208,9 +221,9 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
<div className="lc-ov-first">
|
||||
<div className="lc-ov-kpis">
|
||||
<div className="lc-stat lc-ov-kpi">
|
||||
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
|
||||
<span className="lc-stat-label">{sessionsLabel}</span>
|
||||
<span className="lc-stat-value">{kpi.sessions}</span>
|
||||
<span className="lc-stat-sub">{t('ov.kpi.ofTotal', { n: kpi.listed })}</span>
|
||||
<span className="lc-stat-sub">{sessionsSub}</span>
|
||||
</div>
|
||||
<div className="lc-stat lc-ov-kpi">
|
||||
<span className="lc-stat-label">{t('ov.kpi.tokens')}</span>
|
||||
@@ -300,6 +313,16 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
aria-label={t('ov.list.search')}
|
||||
onChange={(ev) => { setQuery(ev.target.value) }}
|
||||
/>
|
||||
<select
|
||||
className="lc-ov-cover"
|
||||
value={coverage}
|
||||
aria-label={t('ov.cover.label')}
|
||||
onChange={(ev) => { setCoverage(ev.target.value as OverviewCoverage) }}
|
||||
>
|
||||
{COVERAGE.map(c => (
|
||||
<option key={c} value={c}>{t('ov.cover.' + c)}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
|
||||
{SORTS.map(s => (
|
||||
<button
|
||||
|
||||
@@ -480,8 +480,11 @@ export const DICT_EN = {
|
||||
'ov.date.wd.4': 'T',
|
||||
'ov.date.wd.5': 'F',
|
||||
'ov.date.wd.6': 'S',
|
||||
'ov.kpi.allSessions': 'Sessions',
|
||||
'ov.kpi.sessions': 'Active Sessions',
|
||||
'ov.kpi.archived': 'Archived Sessions',
|
||||
'ov.kpi.ofTotal': 'of {n} total',
|
||||
'ov.kpi.inclArchived': 'incl. {n} archived',
|
||||
'ov.kpi.tokens': 'Tokens Used',
|
||||
'ov.kpi.sessionsSub': 'across {n} sessions',
|
||||
'ov.kpi.pricedSub': '{n} of {total} sessions priced',
|
||||
@@ -544,6 +547,10 @@ export const DICT_EN = {
|
||||
'ov.skills.k.path': 'Path',
|
||||
'ov.skills.copyPath': 'Click to copy the path',
|
||||
'ov.skills.copied': 'Copied',
|
||||
'ov.cover.label': 'Filter sessions',
|
||||
'ov.cover.all': 'All (show archived)',
|
||||
'ov.cover.active': 'Hide archived',
|
||||
'ov.cover.archived': 'Archived only',
|
||||
'ov.list.title': 'Sessions',
|
||||
'ov.list.search': 'Search title / path / last message…',
|
||||
'ov.list.sortLabel': 'Sort',
|
||||
@@ -565,6 +572,8 @@ export const DICT_EN = {
|
||||
'ov.running': 'Running',
|
||||
'ov.current': 'current',
|
||||
'ov.card.turns': '{n} turns · {s} steps',
|
||||
'ov.card.archived': 'Archived',
|
||||
'ov.card.archivedTip': 'Archived sessions cannot be opened. Unarchive it to view.',
|
||||
'ov.card.teamUsage': 'Total Tokens',
|
||||
'ov.card.teamCost': 'Total Est. Cost',
|
||||
'ov.card.lastUser': 'Last message',
|
||||
|
||||
@@ -479,8 +479,11 @@ export const DICT_ZH = {
|
||||
'ov.date.wd.4': '四',
|
||||
'ov.date.wd.5': '五',
|
||||
'ov.date.wd.6': '六',
|
||||
'ov.kpi.allSessions': '全部会话',
|
||||
'ov.kpi.sessions': '活跃会话',
|
||||
'ov.kpi.archived': '已归档会话',
|
||||
'ov.kpi.ofTotal': '共 {n} 个',
|
||||
'ov.kpi.inclArchived': '其中已归档 {n} 个',
|
||||
'ov.kpi.tokens': '累计消耗',
|
||||
'ov.kpi.sessionsSub': '含 {n} 个会话',
|
||||
'ov.kpi.pricedSub': '已计价 {n}/{total} 个会话',
|
||||
@@ -543,6 +546,10 @@ export const DICT_ZH = {
|
||||
'ov.skills.k.path': '路径',
|
||||
'ov.skills.copyPath': '点击复制路径',
|
||||
'ov.skills.copied': '已复制',
|
||||
'ov.cover.label': '筛选会话',
|
||||
'ov.cover.all': '全部会话(显示已归档)',
|
||||
'ov.cover.active': '隐藏已归档',
|
||||
'ov.cover.archived': '仅显示已归档',
|
||||
'ov.list.title': '会话',
|
||||
'ov.list.search': '搜索标题 / 目录 / 最后消息…',
|
||||
'ov.list.sortLabel': '排序',
|
||||
@@ -564,6 +571,8 @@ export const DICT_ZH = {
|
||||
'ov.running': '运行中',
|
||||
'ov.current': '当前',
|
||||
'ov.card.turns': '{n} 轮 {s} 步',
|
||||
'ov.card.archived': '已归档',
|
||||
'ov.card.archivedTip': '已归档会话暂无法打开,取消归档后可查看。',
|
||||
'ov.card.teamUsage': 'Token 总用量',
|
||||
'ov.card.teamCost': '总预估费用',
|
||||
'ov.card.lastUser': '最后消息',
|
||||
|
||||
+18
-7
@@ -26,6 +26,8 @@ export interface OverviewRow {
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
current: boolean
|
||||
/** Whether the workspace registry archived this session: the coverage picker's split line. */
|
||||
archived: boolean
|
||||
timeline: ContextTimeline | null
|
||||
/** The TEAM's merged daily ledger: this session's own plus every descendant subagent's. */
|
||||
activity: ContextActivity | null
|
||||
@@ -131,7 +133,8 @@ export function inGroup(row: OverviewRow, group: string, groups: Record<string,
|
||||
return group === UNGROUPED_KEY ? title === undefined : title === group
|
||||
}
|
||||
|
||||
/** Fail open: an absent seat, a hostile shape, or a throwing accessor archives nothing, so filtering never empties the list. */
|
||||
/** Fail open: an absent seat, a hostile shape, or a throwing accessor tags
|
||||
* nothing, so the coverage split keeps every row in the pane's own scope. */
|
||||
function archivedSetOf(workspaces: unknown): Set<string> {
|
||||
try {
|
||||
const value = asRecord(workspaces)?.archivedSessionIds
|
||||
@@ -143,8 +146,9 @@ function archivedSetOf(workspaces: unknown): Set<string> {
|
||||
}
|
||||
|
||||
/** Join the raw session-list snapshot into render-ready rows, or null when it
|
||||
* is unusable (distinct from a real empty list). Blank placeholder and
|
||||
* archived rows drop out; one throwing row costs just itself. */
|
||||
* is unusable (distinct from a real empty list). Blank placeholder rows drop
|
||||
* out, archived rows are tagged for the coverage picker, and one throwing row
|
||||
* costs just itself. */
|
||||
export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): OverviewRow[] | null {
|
||||
const state = asRecord(snapshot)
|
||||
if (state === null) return null
|
||||
@@ -155,7 +159,6 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
|
||||
const current = typeof state.current === 'string' ? state.current : undefined
|
||||
const rows: OverviewRow[] = []
|
||||
for (const id of ids) {
|
||||
if (archived.has(id)) continue
|
||||
try {
|
||||
const row = asRecord(byId[id])
|
||||
if (row === null) continue
|
||||
@@ -175,6 +178,7 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
|
||||
updatedAt: typeof row.updatedAt === 'number' && Number.isFinite(row.updatedAt) ? row.updatedAt : 0,
|
||||
running: row.running === true,
|
||||
current: id === current,
|
||||
archived: archived.has(id),
|
||||
timeline: timelineOf(values?.contextTimeline),
|
||||
activity: activityOf(values?.contextActivity),
|
||||
family: [],
|
||||
@@ -218,6 +222,16 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The coverage picker's scopes: 'active' hides archived sessions, 'archived'
|
||||
* lists only them, and 'all' folds both. */
|
||||
export type OverviewCoverage = 'all' | 'active' | 'archived'
|
||||
|
||||
/** The coverage pass every page reader (KPI band, chart, heatmap, grid) shares:
|
||||
* one list, so the cards can never disagree with the grid on which sessions count. */
|
||||
export function coveredRowsOf(rows: readonly OverviewRow[], coverage: OverviewCoverage): readonly OverviewRow[] {
|
||||
return coverage === 'all' ? rows : rows.filter(row => row.archived === (coverage === 'archived'))
|
||||
}
|
||||
|
||||
/** Merge several sessions' daily ledgers into one family's: per day the tokens
|
||||
* and requests sum, the pricing records merge, and the skill tables merge name by name (loads sum, last-load takes the max). */
|
||||
function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | null {
|
||||
@@ -579,7 +593,6 @@ export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTo
|
||||
|
||||
export interface OverviewKpis {
|
||||
sessions: number
|
||||
listed: number
|
||||
tokens: number
|
||||
turns: number
|
||||
cost: number | null
|
||||
@@ -596,7 +609,6 @@ export interface OverviewKpis {
|
||||
|
||||
export function kpisOf(
|
||||
rows: readonly OverviewRow[],
|
||||
listed: number,
|
||||
book: ModelBook | null | undefined,
|
||||
currency: CostCurrency,
|
||||
): OverviewKpis {
|
||||
@@ -623,7 +635,6 @@ export function kpisOf(
|
||||
}
|
||||
return {
|
||||
sessions: rows.length,
|
||||
listed,
|
||||
tokens: totals?.total ?? 0,
|
||||
turns,
|
||||
cost: estimateSessionCost(usage, book, currency),
|
||||
|
||||
@@ -165,6 +165,8 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
|
||||
.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); }
|
||||
/* The coverage picker neighbors the search box; the search recipe keeps the two one height. */
|
||||
.lc-ov-cover { flex: none; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 6px; padding: 4px 8px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); }
|
||||
|
||||
.lc-ov-groups { display: flex; gap: 6px; flex-wrap: wrap; margin: -2px 0 10px; }
|
||||
.lc-ov-chip { display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 999px; font: inherit; font-size: 11px; line-height: 1.5; padding: 2px 10px; cursor: pointer; max-width: 220px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
@@ -184,6 +186,7 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
|
||||
/* The 2-line clamp keeps one long title from ballooning its row; the full text stays on the tooltip. */
|
||||
.lc-ov-session-title { flex: 1 1 auto; min-width: 0; font-weight: 600; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; }
|
||||
.lc-ov-current { flex: none; font-size: 10px; line-height: 1.5; color: var(--color-blue-500); border: 1px solid color-mix(in srgb, var(--color-blue-500) 45%, transparent); border-radius: 4px; padding: 0 4px; }
|
||||
.lc-ov-archived { 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 4px; }
|
||||
.lc-ov-session-times { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
|
||||
.lc-ov-session-time { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; }
|
||||
.lc-ov-session-created { color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1.4; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -17,6 +17,7 @@ function rowOf(over: Partial<OverviewRow> = {}): OverviewRow {
|
||||
updatedAt: NOW - 3 * 3_600_000,
|
||||
running: false,
|
||||
current: false,
|
||||
archived: false,
|
||||
timeline: null,
|
||||
activity: null,
|
||||
family: [over.timeline ?? null],
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('OverviewPanel', () => {
|
||||
assert.ok(query(m.container, '.lc-ov-first > .lc-card.lc-ov-usage') !== null, 'the usage chart rides the row')
|
||||
// KPI band: 2 sessions in the 30d range, 1750 tokens billed, priced cost, cache hit.
|
||||
const labels = queryAll(m.container, '.lc-stat-label').map(el => el.textContent)
|
||||
assert.deepEqual(labels, ['Active Sessions', 'Tokens Used', 'Cost', 'Cache Hit', 'Tool Calls', 'Active Time'])
|
||||
assert.deepEqual(labels, ['Sessions', 'Tokens Used', 'Cost', 'Cache Hit', 'Tool Calls', 'Active Time'])
|
||||
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
|
||||
assert.equal(values[0], '2')
|
||||
assert.equal(values[1], '1.8k')
|
||||
@@ -189,16 +189,52 @@ describe('OverviewPanel', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('archived sessions drop from every band, not just the card grid', async () => {
|
||||
test('the coverage picker folds archived sessions in by default and its scopes split the page', async () => {
|
||||
const ctx = makeCtx()
|
||||
const { m } = await openPanel(ctx, {
|
||||
useWorkspaces: useHookOf({ ...workspacesSnapshotValue, archivedSessionIds: ['b'] }),
|
||||
})
|
||||
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
|
||||
assert.equal(values[0], '1', 'the archived session leaves the KPI band')
|
||||
const cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
|
||||
// Default 'all': the archived session folds back into every band, tagged on its card.
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-label')[0].textContent, 'Sessions')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-sub')[0].textContent, 'incl. 1 archived')
|
||||
let cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
|
||||
assert.equal(cards.length, 2)
|
||||
const betaCard = cards.find(c => text(c).includes('beta session'))
|
||||
assert.ok(betaCard !== undefined && text(betaCard).includes('Archived'), 'the archived card carries the tag')
|
||||
const select = query<HTMLSelectElement>(m.container, 'select.lc-ov-cover')
|
||||
assert.deepEqual([...select.options].map(o => o.textContent), ['All (show archived)', 'Hide archived', 'Archived only'])
|
||||
// 'active' restores the lighter list; the sub-line names what the full list holds.
|
||||
await actSelect(select, 'active')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-label')[0].textContent, 'Active Sessions')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-sub')[0].textContent, 'of 3 total')
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '1', 'the archived session leaves the KPI band')
|
||||
cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
|
||||
assert.equal(cards.length, 1)
|
||||
assert.ok(text(cards[0]).includes('alpha session'))
|
||||
// 'archived' flips the split: only the tagged session in range stays.
|
||||
await actSelect(select, 'archived')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-label')[0].textContent, 'Archived Sessions')
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stat-sub')[0].textContent, 'of 3 total')
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '1')
|
||||
const archCards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
|
||||
assert.equal(archCards.length, 1)
|
||||
assert.ok(text(archCards[0]).includes('beta session'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an archived card click opens nothing (dsh refuses archived sessions)', async () => {
|
||||
const opened: string[] = []
|
||||
const ctx = new TestClientCtx({
|
||||
services: {
|
||||
uiWorkspace: { openSession: (id: string) => { opened.push(id) } },
|
||||
sessions: {},
|
||||
},
|
||||
})
|
||||
const { m } = await openPanel(ctx, {
|
||||
useWorkspaces: useHookOf({ ...workspacesSnapshotValue, archivedSessionIds: ['b'] }),
|
||||
})
|
||||
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-grid > .lc-ov-session').find(c => text(c).includes('beta session'))!)
|
||||
assert.deepEqual(opened, [], 'the archived card never navigates')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
@@ -531,7 +567,8 @@ describe('OverviewPanel', () => {
|
||||
}))
|
||||
await flush()
|
||||
assert.ok(text(m.container).includes('上下文洞察'))
|
||||
assert.ok(text(m.container).includes('活跃会话'))
|
||||
assert.ok(text(m.container).includes('全部会话'), 'the All default labels the sessions card and the coverage option')
|
||||
assert.ok(text(m.container).includes('仅显示已归档'), 'the coverage options ride the zh dictionary too')
|
||||
assert.ok(text(m.container).includes('Token 统计'), 'the aggregate stats row rides the zh dictionary too')
|
||||
assert.ok(text(m.container).includes('系统提示词'), 'the Context tab\'s composition categories render in zh')
|
||||
assert.equal(query<HTMLButtonElement>(m.container, '.lc-ov-settings').title, '打开插件设置', 'the settings chip rides the zh dictionary too')
|
||||
@@ -641,3 +678,12 @@ async function actType(input: HTMLInputElement, value: string): Promise<void> {
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Pick a coverage option through the React change path (native setter + change event, act-wrapped). */
|
||||
async function actSelect(select: HTMLSelectElement, value: string): Promise<void> {
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value')?.set
|
||||
setter?.call(select, value)
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export function rowOf(over: Partial<OverviewRow> = {}): OverviewRow {
|
||||
updatedAt: 100,
|
||||
running: false,
|
||||
current: false,
|
||||
archived: false,
|
||||
timeline: null,
|
||||
activity: null,
|
||||
family: [over.timeline ?? null],
|
||||
|
||||
@@ -196,9 +196,8 @@ describe('kpisOf', () => {
|
||||
} as unknown as ContextTimeline,
|
||||
}),
|
||||
]
|
||||
const kpi = kpisOf(rows, 5, prices, 'usd')
|
||||
const kpi = kpisOf(rows, prices, 'usd')
|
||||
assert.equal(kpi.sessions, 2)
|
||||
assert.equal(kpi.listed, 5)
|
||||
assert.equal(kpi.tokens, 200)
|
||||
assert.equal(kpi.turns, 5)
|
||||
assert.ok(kpi.cost !== null && Math.abs(kpi.cost - 390e-6) < 1e-12, 'the DeepSeek peak bucket doubles: 2 × (50×0.1 + 100×1 + 10×1 + 40×2) per 1M')
|
||||
@@ -229,7 +228,7 @@ describe('kpisOf', () => {
|
||||
}),
|
||||
rowOf(),
|
||||
]
|
||||
const kpi = kpisOf(rows, 3, prices, 'usd')
|
||||
const kpi = kpisOf(rows, prices, 'usd')
|
||||
assert.equal(kpi.costSessions, 1, 'only the priced session counts toward the cost cell')
|
||||
assert.equal(kpi.usageSessions, 2, 'both billed sessions feed the cache-hit rate')
|
||||
assert.ok(kpi.cost !== null && Math.abs(kpi.cost - 390e-6) < 1e-12, 'the unpriced session adds nothing to the estimate')
|
||||
@@ -238,7 +237,7 @@ describe('kpisOf', () => {
|
||||
})
|
||||
|
||||
test('an unbilled set zeroes and dashes', () => {
|
||||
const kpi = kpisOf([rowOf()], 1, null, 'cny')
|
||||
const kpi = kpisOf([rowOf()], null, 'cny')
|
||||
assert.equal(kpi.tokens, 0)
|
||||
assert.equal(kpi.turns, 0)
|
||||
assert.equal(kpi.cost, null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { billedOf, rowsOfSnapshot, sessionsSnapshotOf, tokenPartsOf } from '../../src/client/overview'
|
||||
import { billedOf, coveredRowsOf, rowsOfSnapshot, sessionsSnapshotOf, tokenPartsOf } from '../../src/client/overview'
|
||||
import { mergeCostUsage } from '../../src/client/cost'
|
||||
import type { SessionCostUsage } from '../../src/shared/types'
|
||||
import { COST } from './overview.fixtures'
|
||||
@@ -71,7 +71,7 @@ describe('rowsOfSnapshot', () => {
|
||||
assert.equal(rows.length, 4)
|
||||
const [b, c, d, e] = rows
|
||||
assert.deepEqual(b, {
|
||||
id: 'b', title: 'shown', updatedAt: 5, running: true, current: true,
|
||||
id: 'b', title: 'shown', updatedAt: 5, running: true, current: true, archived: false,
|
||||
timeline: null, activity: null, family: [null], familyCost: null,
|
||||
})
|
||||
assert.equal(c.title, 'titled')
|
||||
@@ -82,7 +82,7 @@ describe('rowsOfSnapshot', () => {
|
||||
assert.equal(e.updatedAt, 0, 'a non-numeric stamp zeroes')
|
||||
})
|
||||
|
||||
test('archived sessions drop; a hostile archive set fails open', () => {
|
||||
test('archived sessions are tagged, not dropped; a hostile archive set fails open', () => {
|
||||
const snap = snapshotOf([
|
||||
['a', { displayTitle: 'live' }],
|
||||
['b', { displayTitle: 'archived' }],
|
||||
@@ -90,7 +90,10 @@ describe('rowsOfSnapshot', () => {
|
||||
])
|
||||
const rows = rowsOfSnapshot(snap, { archivedSessionIds: ['b', 7, null] })
|
||||
assert.ok(rows !== null)
|
||||
assert.deepEqual(rows.map(r => r.id), ['a', 'c'], 'archived ids drop; non-string entries archive nothing')
|
||||
assert.deepEqual(rows.map(r => r.id), ['a', 'b', 'c'])
|
||||
assert.equal(rows[1].archived, true, 'the archived id tags its row')
|
||||
assert.equal(rows[0].archived, false)
|
||||
assert.equal(rows[2].archived, false, 'non-string entries archive nothing')
|
||||
// Fail-open shapes: an absent seat, a bare record, a malformed set, and a throwing accessor all keep the full list.
|
||||
assert.equal(rowsOfSnapshot(snap)?.length, 3)
|
||||
assert.equal(rowsOfSnapshot(snap, null)?.length, 3)
|
||||
@@ -247,7 +250,8 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('hostile descendant projections degrade to null members, never a throw', () => { const rows = rowsOfSnapshot(snapshotOf([
|
||||
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>],
|
||||
@@ -259,3 +263,18 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
assert.equal(billedOf(rows[0]), 200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coveredRowsOf', () => {
|
||||
test('all passes the list through; the narrow scopes split on the archived tag', () => {
|
||||
const rows = [
|
||||
{ id: 'a', archived: false },
|
||||
{ id: 'b', archived: true },
|
||||
{ id: 'c', archived: true },
|
||||
] as unknown as Parameters<typeof coveredRowsOf>[0]
|
||||
assert.equal(coveredRowsOf(rows, 'all'), rows)
|
||||
assert.deepEqual(coveredRowsOf(rows, 'active').map(r => r.id), ['a'])
|
||||
assert.deepEqual(coveredRowsOf(rows, 'archived').map(r => r.id), ['b', 'c'])
|
||||
assert.equal(coveredRowsOf([], 'archived').length, 0)
|
||||
assert.deepEqual(coveredRowsOf([], 'all'), [])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user