The events card's position column spelled the same fact out in full
("第 1 轮 · 第 9 步"), crowding the row's meta tail on narrow panes. It now
uses the notation the browser's own rows already carry (browser.stepAt):
T1 S9, T2 S3→4, T50 S8 → T51 S1.
eventAt() is shared, so the request detail's marker chip and the trend
chart's marker tooltip read the compact form too — one notation for one datum.
465 lines
24 KiB
TypeScript
465 lines
24 KiB
TypeScript
import { createElement as h } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { describe, test } from 'vitest'
|
||
import { makeRequestDetail } from '../../../src/client/components/requestDetail'
|
||
import { makeStackedBar } from '../../../src/client/components/stackedBar'
|
||
import type { RequestRecord, ContextEventRecord, SurfaceNode } from '../../../src/shared/types'
|
||
import type { ConversationNodeLike } from '../../../src/client/services'
|
||
import type { StepBrief } from '../../../src/client/brief'
|
||
import { makeKit, mount, click, queryAll, query, surfaceNode, text } from '../helpers/kit'
|
||
|
||
const kit = makeKit()
|
||
const RequestDetail = makeRequestDetail(kit, makeStackedBar(kit))
|
||
|
||
function req(over: Partial<RequestRecord>): RequestRecord {
|
||
return {
|
||
time: 1_700_000_000_000, seq: 10, turn: 1, step: 1,
|
||
system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 250, total: 1300,
|
||
...over,
|
||
}
|
||
}
|
||
|
||
|
||
/** Full-category fixture so every row renders a non-zero figure. */
|
||
const FULL = { system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 250, total: 1300 }
|
||
|
||
describe('RequestDetail header', () => {
|
||
test('null request renders nothing', async () => {
|
||
const m = await mount(h(RequestDetail, { request: null }))
|
||
assert.equal(m.container.innerHTML, '')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('step detail: title, time, usage chips, per-category rows', async () => {
|
||
const m = await mount(h(RequestDetail, {
|
||
request: req({ prompt: 1000, output: 120, cacheRead: 500 }),
|
||
}))
|
||
const head = text(query(m.container, '.lc-detail-head'))
|
||
assert.ok(head.includes('Turn 1 · Step 1'))
|
||
assert.ok(head.includes('Turn 1 · Step 1 of 1'), 'no stepsOf getter degrades to a 1-step turn')
|
||
assert.ok(!head.includes('Last Step'), 'single-step request carries no last-step tag')
|
||
assert.ok(!head.includes('Delta'), 'cumulative mode carries no delta tag')
|
||
assert.ok(head.includes('Actual 1.0k'))
|
||
assert.ok(head.includes('Output 120'))
|
||
assert.ok(head.includes('Cache 50.00%'), 'real cacheHitPercent: 500/1000 truncated to two decimals')
|
||
const ms = await mount(h(RequestDetail, { request: req({}), stepsOf: (turn) => (turn === 1 ? 7 : 1) }))
|
||
assert.ok(text(query(ms.container, '.lc-detail-head')).includes('Turn 1 · Step 1 of 7'))
|
||
await ms.unmount()
|
||
const rows = queryAll(m.container, '.lc-detail-row')
|
||
assert.equal(rows.length, 7)
|
||
assert.ok(text(rows[0]).includes('System Prompt'))
|
||
assert.ok(text(rows[0]).includes('≈100'))
|
||
const pcts = queryAll(m.container, '.lc-detail-pct').map(el => text(el))
|
||
assert.ok(pcts.some(p => p.endsWith('%')), 'percentages rendered when total > 0')
|
||
const fills = queryAll<HTMLElement>(m.container, '.lc-bar-fill')
|
||
assert.equal(fills.length, 7, 'one fill per row in cumulative mode')
|
||
assert.ok(fills[0].style.width !== '0%', 'non-zero categories fill the track')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('usage chips drop out when the figures are absent; cache falls back to a dash when nothing was billed', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({}) }))
|
||
const head = text(query(m.container, '.lc-detail-head'))
|
||
assert.ok(!head.includes('Actual'))
|
||
assert.ok(!head.includes('Output'))
|
||
assert.ok(!head.includes('Cache'))
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(RequestDetail, { request: req({ prompt: 0, cacheRead: 0 }) }))
|
||
const head2 = text(query(m2.container, '.lc-detail-head'))
|
||
assert.ok(head2.includes('Cache —%'), 'prompt 0 → cacheHitPercent null → dash')
|
||
assert.ok(!head2.includes('Output'))
|
||
await m2.unmount()
|
||
})
|
||
|
||
test('turn aggregate shows the step count and the last-step tag; stepCount 1 stays a step', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({ turn: 2, stepCount: 3 }) }))
|
||
const head = text(query(m.container, '.lc-detail-head'))
|
||
assert.ok(head.includes('Turn 2 · 3 steps'))
|
||
assert.ok(head.includes('Last Step'))
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(RequestDetail, { request: req({ turn: 2, step: 4, stepCount: 1 }) }))
|
||
const head2 = text(query(m2.container, '.lc-detail-head'))
|
||
assert.ok(head2.includes('Turn 2 · Step 4'))
|
||
assert.ok(!head2.includes('Last Step'))
|
||
await m2.unmount()
|
||
|
||
// Requests without turn/step stamps degrade to zeroes.
|
||
const bare: RequestRecord = {
|
||
time: 1_700_000_000_000, seq: 3,
|
||
system: 1, tools: 1, user: 1, inject: 0, assistant: 1, tool: 0, total: 4,
|
||
}
|
||
const m3 = await mount(h(RequestDetail, { request: bare }))
|
||
assert.ok(text(query(m3.container, '.lc-detail-head')).includes('Turn 0 · Step 0'))
|
||
await m3.unmount()
|
||
const m4 = await mount(h(RequestDetail, { request: { ...bare, stepCount: 2 } }))
|
||
assert.ok(text(query(m4.container, '.lc-detail-head')).includes('Turn 0 · 2 steps'))
|
||
await m4.unmount()
|
||
})
|
||
|
||
test('boundary marker shows where the event happened; in-flight markers hide', async () => {
|
||
const marker: ContextEventRecord = {
|
||
seq: 9, time: 1_699_999_999_000, kind: 'compaction', count: 3,
|
||
fromTurn: 2, fromStep: 3, turn: 2, step: 4,
|
||
}
|
||
const m = await mount(h(RequestDetail, { request: req({}), marker }))
|
||
const chip = query(m.container, '.lc-detail-marker')
|
||
assert.ok(text(chip).includes('✂ T2 S3→4'))
|
||
assert.equal(chip.getAttribute('title'), 'Context compacted (summary replaced 3 messages)')
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
marker: { seq: 9, time: 1, kind: 'prune', fromTurn: 2, fromStep: 8, turn: 3, step: 1 },
|
||
}))
|
||
assert.ok(text(query(m2.container, '.lc-detail-marker')).includes('T2 S8 → T3 S1'))
|
||
await m2.unmount()
|
||
|
||
// Same-request boundary (no from-stamps).
|
||
const m3 = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
marker: { seq: 9, time: 1, kind: 'compaction', count: 2, turn: 4, step: 2 },
|
||
}))
|
||
assert.ok(text(query(m3.container, '.lc-detail-marker')).includes('✂ T4 S2'))
|
||
await m3.unmount()
|
||
|
||
// No turn/step stamps (event in flight) → no marker chip.
|
||
const m4 = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
marker: { seq: 9, time: 1, kind: 'prune' },
|
||
}))
|
||
assert.equal(queryAll(m4.container, '.lc-detail-marker').length, 0)
|
||
await m4.unmount()
|
||
|
||
const m5 = await mount(h(RequestDetail, { request: req({}), marker: null }))
|
||
assert.equal(queryAll(m5.container, '.lc-detail-marker').length, 0)
|
||
await m5.unmount()
|
||
})
|
||
})
|
||
|
||
describe('RequestDetail delta mode', () => {
|
||
test('signed deltas vs the previous record: net chip, diverging rows, no usage chips', async () => {
|
||
const request = req({ system: 150, tools: 200, user: 250, inject: 0, assistant: 300, tool: 350, total: 1250, prompt: 900 })
|
||
const prev = req({ ...FULL, inject: 0 })
|
||
const m = await mount(h(RequestDetail, { request, prev }))
|
||
const head = text(query(m.container, '.lc-detail-head'))
|
||
assert.ok(head.includes('Delta'), 'delta tag shown')
|
||
assert.ok(!head.includes('Actual'), 'usage chips drop out in delta mode')
|
||
const metric = query(m.container, '.lc-detail-metric')
|
||
// +50 -50 -100 +100 → net 0 → neutral chip.
|
||
assert.ok(text(metric).includes('Δ 0'))
|
||
assert.ok(!metric.className.includes('lc-detail-metric-up'))
|
||
assert.ok(!metric.className.includes('lc-detail-metric-down'))
|
||
const zeros = queryAll(m.container, '.lc-bar-zero')
|
||
assert.equal(zeros.length, 7, 'every delta row carries the zero line')
|
||
const upFills = queryAll(m.container, '.lc-bar-fill-up')
|
||
const downFills = queryAll(m.container, '.lc-bar-fill-down')
|
||
assert.equal(upFills.length, 2, 'system +50, tool +100')
|
||
assert.equal(downFills.length, 2, 'user -50, assistant -100')
|
||
// maxAbs = 100 (assistant/tool): tool fills the full half-track, system half of it.
|
||
assert.equal((upFills[1] as HTMLElement).style.width, '50%')
|
||
assert.equal((upFills[0] as HTMLElement).style.width, '25%')
|
||
const nums = queryAll(m.container, '.lc-detail-num')
|
||
assert.ok(text(nums[0]).includes('+50'))
|
||
assert.ok(nums[0].className.includes('lc-detail-num-up'))
|
||
assert.ok(text(nums[2]).includes('-50'))
|
||
assert.ok(nums[2].className.includes('lc-detail-num-down'))
|
||
assert.ok(!nums[1].className.includes('lc-detail-num-up'), 'zero delta is neutral')
|
||
assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === ''), 'no percentages in delta mode')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('delta with prev null reads against zeros (all up); shrink-only deltas go down', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({}), prev: null }))
|
||
const metric = query(m.container, '.lc-detail-metric')
|
||
assert.ok(metric.className.includes('lc-detail-metric-up'))
|
||
assert.ok(text(metric).includes('Δ +1.3k'))
|
||
assert.equal(queryAll(m.container, '.lc-bar-fill-down').length, 0)
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(RequestDetail, {
|
||
request: req({ system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }),
|
||
prev: req({ ...FULL }),
|
||
}))
|
||
const metric2 = query(m2.container, '.lc-detail-metric')
|
||
assert.ok(metric2.className.includes('lc-detail-metric-down'))
|
||
assert.ok(text(metric2).includes('Δ -1.3k'))
|
||
assert.equal(queryAll(m2.container, '.lc-bar-fill-up').length, 0)
|
||
assert.equal(queryAll(m2.container, '.lc-bar-fill-down').length, 6)
|
||
await m2.unmount()
|
||
})
|
||
|
||
test('cumulative rows with a zero total render zero-width fills and no percentages', async () => {
|
||
const m = await mount(h(RequestDetail, {
|
||
request: req({ system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }),
|
||
}))
|
||
for (const fill of queryAll<HTMLElement>(m.container, '.lc-bar-fill')) {
|
||
assert.equal(fill.style.width, '0%')
|
||
}
|
||
assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === ''))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('RequestDetail composition bar hover-link', () => {
|
||
test('the mirrored hoverKey dims the bar, lights the matching segment and detail row; the tip stays off', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({}), hoverKey: 'tools' }))
|
||
const bar = query(m.container, '.lc-stacked')
|
||
assert.ok(bar.className.includes('lc-stacked-dim'), 'a mirrored hover dims the bar')
|
||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||
assert.equal(segs.length, 6, 'all six categories render')
|
||
assert.ok(segs[1].className.includes('lc-stacked-seg-on'), 'exactly the tools segment (CATS order) lights')
|
||
assert.ok(segs.filter(s => s.className.includes('lc-stacked-seg-on')).length === 1)
|
||
const rows = queryAll(m.container, '.lc-detail-row')
|
||
assert.ok(rows[1].className.includes('lc-detail-row-on'), 'the tools row lights too')
|
||
assert.ok(rows.filter(r => r.className.includes('lc-detail-row-on')).length === 1)
|
||
assert.equal(queryAll(m.container, '.lc-bar-tip').length, 0, 'a mirrored hover floats no tooltip')
|
||
|
||
const m2 = await mount(h(RequestDetail, { request: req({}) }))
|
||
assert.equal(query(m2.container, '.lc-stacked').className, 'lc-stacked')
|
||
assert.equal(queryAll(m2.container, '.lc-detail-row-on').length, 0)
|
||
assert.equal(queryAll(m2.container, '.lc-bar-tip').length, 0)
|
||
await m2.unmount()
|
||
await m.unmount()
|
||
})
|
||
|
||
test('delta-mode detail rows light the matching category too', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({}), prev: req({ ...FULL }), hoverKey: 'user' }))
|
||
const rows = queryAll(m.container, '.lc-detail-row')
|
||
assert.ok(rows[2].className.includes('lc-detail-row-on'), 'the user row lights in delta mode')
|
||
assert.ok(rows.filter(r => r.className.includes('lc-detail-row-on')).length === 1)
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('RequestDetail brief section', () => {
|
||
const convOk: ConversationNodeLike = {
|
||
kind: 'tool-result', seq: 21,
|
||
call: { name: 'bash', argsRaw: '{"description":"list files"}' },
|
||
content: [{ type: 'text', text: 'a.txt' }],
|
||
}
|
||
|
||
test('the brief lane is constant: absent/empty briefs reserve its height, not collapse', async () => {
|
||
const m = await mount(h(RequestDetail, { request: req({}), brief: null }))
|
||
assert.equal(queryAll(m.container, '.lc-brief').length, 1, 'the lane always renders')
|
||
assert.equal(queryAll(m.container, '.lc-brief-row').length, 1, 'only the always-present In row placeholder')
|
||
await m.unmount()
|
||
|
||
const empty: StepBrief = { inputs: [] }
|
||
const m2 = await mount(h(RequestDetail, { request: req({}), brief: empty }))
|
||
assert.equal(queryAll(m2.container, '.lc-brief').length, 1)
|
||
assert.equal(queryAll(m2.container, '.lc-brief-row').length, 1, 'only the always-present In row')
|
||
await m2.unmount()
|
||
})
|
||
|
||
test('opener, inputs with overflow, and reply rows render with tags and previews', async () => {
|
||
const brief: StepBrief = {
|
||
opener: surfaceNode({ seq: 1, cat: 'user', text: 'please refactor this' }),
|
||
inputs: [
|
||
surfaceNode({ seq: 21, cat: 'tool', tool: 'bash' }),
|
||
surfaceNode({ seq: 22, cat: 'inject', form: 'snapshot', text: 'state v2' }),
|
||
surfaceNode({ seq: 23, cat: 'user', text: 'extra note', imgs: 3 }),
|
||
surfaceNode({ seq: 24, cat: 'inject', form: 'notice' }),
|
||
],
|
||
response: surfaceNode({ seq: 25, cat: 'assistant', calls: ['bash', 'write'], text: 'done' }),
|
||
}
|
||
const m = await mount(h(RequestDetail, {
|
||
request: req({}), brief,
|
||
convOf: (seq) => seq === 21 ? convOk : undefined,
|
||
}))
|
||
const rows = queryAll(m.container, '.lc-brief-row')
|
||
assert.equal(rows.length, 3)
|
||
assert.ok(text(rows[0]).includes('please refactor this'), 'opener row carries the user text')
|
||
assert.ok(text(rows[0]).includes('User'), 'opener tagged as the turn opener')
|
||
const chips = queryAll(rows[1], '.lc-brief-chip')
|
||
assert.equal(chips.length, 3, 'inputs capped at MAX_CHIPS')
|
||
assert.ok(text(rows[1]).includes('+1'), 'overflow pill counts the hidden inputs')
|
||
assert.ok(text(chips[0]).includes('bash') && text(chips[0]).includes('list files'), 'tool chip: name tag + call summary')
|
||
assert.ok(text(chips[1]).includes('State Snapshot') && text(chips[1]).includes('Snapshot: state v2'))
|
||
assert.ok(text(chips[2]).includes('Image ×3'), 'user chip counts attachments')
|
||
assert.equal(rows[0].tagName, 'DIV')
|
||
assert.ok(!chips[0].className.includes('lc-brief-chip-link'))
|
||
const replyChip = query(rows[2], '.lc-brief-chip')
|
||
assert.ok(replyChip.className.includes('lc-brief-chip-grow'))
|
||
assert.ok(text(replyChip).includes('bash › write'))
|
||
assert.ok(text(replyChip).includes('done'))
|
||
assert.equal(queryAll(m.container, '.lc-brief-tip').length, 3)
|
||
assert.equal(chips[0].getAttribute('title'), 'bash · list files')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('locate linkage: row clicks locate their node; chip clicks locate their own node only', async () => {
|
||
const calls: { seq: number; isResponse: boolean }[] = []
|
||
const onLocate = (n: SurfaceNode, isResponse: boolean): void => {
|
||
calls.push({ seq: n.seq, isResponse })
|
||
}
|
||
const brief: StepBrief = {
|
||
opener: surfaceNode({ seq: 1, cat: 'user', text: 'open' }),
|
||
inputs: [
|
||
surfaceNode({ seq: 21, cat: 'tool', tool: 'bash', err: true }),
|
||
surfaceNode({ seq: 22, cat: 'inject', form: 'catalog', text: 'tools changed' }),
|
||
],
|
||
response: surfaceNode({ seq: 25, cat: 'assistant', text: 'reply text' }),
|
||
}
|
||
const m = await mount(h(RequestDetail, {
|
||
request: req({}), brief, onLocate,
|
||
convOf: (seq) => seq === 21 ? convOk : undefined,
|
||
}))
|
||
const rows = queryAll(m.container, '.lc-brief-row')
|
||
assert.equal(rows[0].tagName, 'BUTTON', 'rows become buttons when locate is wired')
|
||
assert.equal(queryAll(rows[1], '.lc-br-err-dot').length, 1)
|
||
const chip = queryAll(rows[1], '.lc-brief-chip')[1]
|
||
assert.ok(chip.className.includes('lc-brief-chip-link'))
|
||
await click(chip)
|
||
assert.deepEqual(calls, [{ seq: 22, isResponse: false }], 'stopPropagation keeps the row handler out')
|
||
await click(rows[1] as HTMLElement)
|
||
assert.deepEqual(calls[1], { seq: 21, isResponse: false })
|
||
await click(rows[2] as HTMLElement)
|
||
assert.deepEqual(calls[2], { seq: 25, isResponse: true })
|
||
const replyChip = query(rows[2], '.lc-brief-chip')
|
||
assert.ok((replyChip.getAttribute('title') ?? '').includes('Reveal in Context Browser'))
|
||
await click(rows[0] as HTMLElement)
|
||
assert.deepEqual(calls[3], { seq: 1, isResponse: false })
|
||
await m.unmount()
|
||
})
|
||
|
||
test('chipParts cascade: tool/assistant/inject/user kinds and the skill-inject fallback', async () => {
|
||
const inputs: SurfaceNode[] = [
|
||
// skill: a `skill`-tool load — name tag wins, call summary through the join.
|
||
surfaceNode({ seq: 30, cat: 'skill', skill: 'code-review', tool: 'skill' }),
|
||
// tool: name tag, no join → no text.
|
||
surfaceNode({ seq: 31, cat: 'tool', tool: 'read' }),
|
||
// tool: no name, no join → untagged Tool Result placeholder.
|
||
surfaceNode({ seq: 32, cat: 'tool' }),
|
||
// assistant: text + call list breadcrumb.
|
||
surfaceNode({ seq: 33, cat: 'assistant', calls: ['bash'], text: 'looking' }),
|
||
// assistant: textless, block summary through the join.
|
||
surfaceNode({ seq: 34, cat: 'assistant', calls: ['write'] }),
|
||
// assistant: no own calls; the breadcrumb is recovered from the join.
|
||
surfaceNode({ seq: 35, cat: 'assistant' }),
|
||
// assistant: truly empty reply.
|
||
surfaceNode({ seq: 36, cat: 'assistant', calls: [] }),
|
||
// inject: plain form with text.
|
||
surfaceNode({ seq: 37, cat: 'inject', form: 'relay', text: 'from agent' }),
|
||
// inject: form absent → Context Injection.
|
||
surfaceNode({ seq: 38, cat: 'inject', text: 'plain' }),
|
||
// user: single image, no text.
|
||
surfaceNode({ seq: 39, cat: 'user', imgs: 1 }),
|
||
// user: plain text.
|
||
surfaceNode({ seq: 40, cat: 'user', text: 'hi' }),
|
||
]
|
||
const convs: Record<number, ConversationNodeLike> = {
|
||
30: { kind: 'tool-result', seq: 30, call: { name: 'bash', argsRaw: '{"description":"load skill"}' } },
|
||
34: { kind: 'assistant', seq: 34, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] },
|
||
35: { kind: 'assistant', seq: 35, blocks: [{ kind: 'tool-call', name: 'read', argsRaw: '{}' }] },
|
||
44: { kind: 'assistant', seq: 44, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] },
|
||
}
|
||
// One row per input would need 11 rows; instead render them as one inputs
|
||
// list — chips are the unit under test (3 shown + overflow), so render batches through separate mounts.
|
||
const render = async (list: SurfaceNode[]) => {
|
||
const m = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
brief: { inputs: list },
|
||
convOf: (seq) => convs[seq],
|
||
}))
|
||
return m
|
||
}
|
||
const chipText = (m: Awaited<ReturnType<typeof render>>, i: number) =>
|
||
text(queryAll(m.container, '.lc-brief-chip')[i])
|
||
|
||
const m1 = await render(inputs.slice(0, 3))
|
||
assert.ok(chipText(m1, 0).includes('Skill · code-review') && chipText(m1, 0).includes('load skill'))
|
||
assert.equal(chipText(m1, 1), 'read', 'tool name tag without a join leaves no text')
|
||
assert.equal(chipText(m1, 2), 'Tool Result', 'untagged tool falls back to the placeholder text')
|
||
await m1.unmount()
|
||
|
||
const m2 = await render(inputs.slice(3, 6))
|
||
assert.ok(chipText(m2, 0).includes('bash') && chipText(m2, 0).includes('looking'))
|
||
assert.ok(chipText(m2, 1).includes('write') && chipText(m2, 1).includes('a.ts'), 'block summary through the join')
|
||
assert.equal(chipText(m2, 2), 'read', 'breadcrumb from the join, no preview text')
|
||
await m2.unmount()
|
||
|
||
const m3 = await render(inputs.slice(6, 9))
|
||
assert.equal(chipText(m3, 0), '(empty reply)')
|
||
assert.ok(chipText(m3, 1).includes('Agent Relay') && chipText(m3, 1).includes('from agent'))
|
||
assert.ok(chipText(m3, 2).includes('Context Injection') && chipText(m3, 2).includes('plain'))
|
||
await m3.unmount()
|
||
|
||
const m4 = await render(inputs.slice(9))
|
||
assert.equal(chipText(m4, 0), 'Image', 'single image: no count suffix')
|
||
assert.equal(chipText(m4, 1), 'hi')
|
||
await m4.unmount()
|
||
|
||
const m5 = await render([surfaceNode({ seq: 41, cat: 'skill', skill: 'ponytail' })])
|
||
assert.equal(chipText(m5, 0), 'Skill · ponytail', 'a text-less skill row tags the name')
|
||
await m5.unmount()
|
||
const m6 = await render([surfaceNode({ seq: 42, cat: 'skill', skill: 'ponytail', text: 'notes inside' })])
|
||
assert.ok(chipText(m6, 0).includes('Skill · ponytail') && chipText(m6, 0).includes('notes inside'))
|
||
await m6.unmount()
|
||
|
||
// A category outside the fold's vocabulary (host drift) degrades into the user tail — a plain text row, never a throw.
|
||
const m7 = await render([surfaceNode({ seq: 43, cat: 'mystery' as never, text: 'survivor' })])
|
||
assert.equal(chipText(m7, 0), 'survivor', 'hostile cat renders its text plainly')
|
||
await m7.unmount()
|
||
const m8 = await render([surfaceNode({ seq: 44, cat: 'mystery' as never, calls: ['bash', 'read'] })])
|
||
assert.equal(chipText(m8, 0), '', 'hostile cat with no text renders an inert chip')
|
||
await m8.unmount()
|
||
|
||
const m11 = await render([surfaceNode({ seq: 47, cat: 'skill', skill: 'grilling', tool: 'skill' })])
|
||
assert.equal(chipText(m11, 0), 'Skill · grilling')
|
||
await m11.unmount()
|
||
// The catalog digest tags its form; a nameless, formless skill row degrades to the context label.
|
||
const m11b = await render([
|
||
surfaceNode({ seq: 471, cat: 'skill', form: 'catalog' }),
|
||
surfaceNode({ seq: 472, cat: 'skill' }),
|
||
])
|
||
assert.equal(chipText(m11b, 0), 'Catalog Update')
|
||
assert.equal(chipText(m11b, 1), 'Context Injection')
|
||
await m11b.unmount()
|
||
const m12 = await render([surfaceNode({ seq: 48, cat: 'inject', form: 'notice' })])
|
||
assert.equal(chipText(m12, 0), 'Notice')
|
||
await m12.unmount()
|
||
})
|
||
|
||
test('the In row always renders: muted empty state when nothing entered, chips otherwise', async () => {
|
||
const calls: { seq: number; isResponse: boolean }[] = []
|
||
const onLocate = (n: SurfaceNode, isResponse: boolean): void => {
|
||
calls.push({ seq: n.seq, isResponse })
|
||
}
|
||
const brief: StepBrief = {
|
||
opener: surfaceNode({ seq: 1, cat: 'user' }),
|
||
inputs: [],
|
||
}
|
||
const m = await mount(h(RequestDetail, { request: req({}), brief, onLocate }))
|
||
const rows = queryAll(m.container, '.lc-brief-row')
|
||
assert.equal(rows.length, 2, 'the opener row plus the ALWAYS-present In row')
|
||
assert.equal(queryAll(rows[0], '.lc-brief-fact').length, 0, 'no fact tag on a plain user opener')
|
||
assert.equal(queryAll(rows[0], '.lc-brief-text').length, 0, 'no text span without preview')
|
||
assert.ok(text(rows[1]).includes('(nothing new)'), 'the empty In row carries the placeholder')
|
||
assert.equal(queryAll(rows[1], '.lc-brief-chip').length, 0, 'empty state carries no chips')
|
||
await click(rows[1] as HTMLElement)
|
||
assert.deepEqual(calls, [], 'a row without a node stays inert even with locate wired')
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
brief: { inputs: [surfaceNode({ seq: 5, cat: 'user', text: 'just input' })] },
|
||
}))
|
||
const rows2 = queryAll(m2.container, '.lc-brief-row')
|
||
assert.equal(rows2.length, 1)
|
||
assert.ok(text(rows2[0]).includes('In'), 'the single row is the inputs row')
|
||
assert.equal(queryAll(m2.container, '.lc-brief-more').length, 0, 'no overflow pill at the cap')
|
||
await m2.unmount()
|
||
|
||
const m3 = await mount(h(RequestDetail, {
|
||
request: req({}),
|
||
brief: { opener: surfaceNode({ seq: 7, cat: 'user', text: 'see attached', imgs: 2 }), inputs: [] },
|
||
}))
|
||
const fact = query(m3.container, '.lc-brief-fact')
|
||
assert.equal(text(fact), 'Image ×2')
|
||
assert.ok(text(query(m3.container, '.lc-brief-text')).includes('see attached'))
|
||
await m3.unmount()
|
||
})
|
||
})
|