fix(B01): retain native card state and restore mode and confirmation styles
This commit is contained in:
1 parent
ddd677bae3
commit
1ed71b63b5
14 files changed
+407
-37
No files matched your search
@@ -0,0 +1,99 @@
|
||||
/** F01 acceptance reproduction extended through the real reducer and MessageBubble.
|
||||
* No Electron, model, SDK, or external service; DOM behavior, not visual parity.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import type { ChatMessage, ChatEvent, MessagePart } from '../src/shared/types'
|
||||
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
||||
const dom = new JSDOM('<!doctype html><html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
|
||||
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
const { act } = require('react') as typeof import('react')
|
||||
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
|
||||
const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
|
||||
const { applyAssistantEvent } = require('../src/shared/message-parts') as typeof import('../src/shared/message-parts')
|
||||
const { completedFoldPartIdentity } = require('../src/renderer/src/workbuddy/conversation/completed-fold') as typeof import('../src/renderer/src/workbuddy/conversation/completed-fold')
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
const plan = '.cb-plan-header-left'
|
||||
const task = '.cb-task-result-item-title'
|
||||
const outer = '.cr-collapse--completed > .cr-collapse__header'
|
||||
const nodes = (selector: string) => [...document.querySelectorAll(selector)]
|
||||
const get = (selector: string, index = 0) => { const node = nodes(selector)[index]; assert.ok(node, `${selector}[${index}]`); return node }
|
||||
const states = () => ({ plan: nodes(plan).map(node => node.getAttribute('aria-expanded')), task: nodes(task).map(node => node.getAttribute('aria-expanded')) })
|
||||
async function render(message: ChatMessage) { await act(async () => root.render(<MessageBubble message={message} />)) }
|
||||
async function click(selector: string, index = 0) { await act(async () => get(selector, index).dispatchEvent(new MouseEvent('click', { bubbles: true }))) }
|
||||
type EventInput = ChatEvent extends infer E ? E extends ChatEvent ? Omit<E, 'runId'> : never : never
|
||||
const event = (value: EventInput) => ({ runId: 'native-identity', ...value }) as ChatEvent
|
||||
const planPart = (id: string): Extract<MessagePart, { type: 'plan' }> => ({ type: 'plan', plan: { id, title: `Plan ${id}`, steps: [] } })
|
||||
const taskPart = (id: string): Extract<MessagePart, { type: 'task' }> => ({ type: 'task', task: { id, title: `Task ${id}`, status: 'success', summary: `Result ${id}` } })
|
||||
async function main() {
|
||||
// All native business IDs, including non-disclosure image/artifact parts, are stable.
|
||||
const stableParts: MessagePart[] = [planPart('same'), taskPart('same'), { type: 'artifact', artifact: { id: 'same', name: 'File', path: '/file' } }, { type: 'image', id: 'same', url: '/image' }, { type: 'reasoning', id: 'same', text: 'Thought' }, { type: 'tool', toolId: 'same' }]
|
||||
const identities = stableParts.map(part => completedFoldPartIdentity(part, 1))
|
||||
assert.equal(new Set(identities).size, stableParts.length, 'part types cannot share identity')
|
||||
stableParts.forEach((part, i) => assert.equal(completedFoldPartIdentity(part, 99), identities[i], `${part.type} ignores position`))
|
||||
assert.notEqual(completedFoldPartIdentity(planPart('1'), 1), completedFoldPartIdentity({ type: 'plan', plan: {} }, 1), 'business IDs cannot collide with fallback indices')
|
||||
assert.equal(completedFoldPartIdentity({ type: 'future', id: 'native' }, 1), completedFoldPartIdentity({ type: 'future', id: 'native' }, 2), 'unknown top-level IDs remain supported')
|
||||
|
||||
let message: ChatMessage = { id: 'native-one', role: 'assistant', createdAt: 1, content: '', status: 'streaming', parts: [] }
|
||||
// Exact acceptance event sequence: two text chunks separated by reasoning.
|
||||
const streamingEvents: EventInput[] = [
|
||||
{ type: 'text', delta: 'Draft one' },
|
||||
{ type: 'part', part: { type: 'reasoning', id: 'r', text: 'Thought', finished: true } },
|
||||
{ type: 'text', delta: 'Draft two' },
|
||||
{ type: 'part', part: planPart('p') },
|
||||
{ type: 'part', part: taskPart('x') }
|
||||
]
|
||||
for (const value of streamingEvents) message = applyAssistantEvent(message, event(value))
|
||||
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'text', 'plan', 'task'])
|
||||
await render(message)
|
||||
assert.deepEqual(states(), { plan: ['true'], task: ['true'] })
|
||||
await click(plan); await click(task)
|
||||
const collapsed = { plan: ['false'], task: ['false'] }
|
||||
assert.deepEqual(states(), collapsed)
|
||||
message = applyAssistantEvent(message, event({ type: 'done', content: 'Corrected final prose', tools: [], turnDurationMs: 1000 }))
|
||||
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'plan', 'task'], 'real done reducer coalesces text')
|
||||
await render(message)
|
||||
assert.deepEqual(states(), collapsed, 'terminal correction preserves choices')
|
||||
for (let repeat = 0; repeat < 2; repeat++) {
|
||||
await click(outer); assert.deepEqual(states(), collapsed, 'outer expansion preserves choices')
|
||||
await click(outer); assert.deepEqual(states(), collapsed, 'outer collapse preserves choices')
|
||||
}
|
||||
// Insert new same-kind cards before originals, then reorder. New cards must not inherit state.
|
||||
const prefix = message.parts!.filter(part => part.type !== 'plan' && part.type !== 'task')
|
||||
message = { ...message, parts: [...prefix, planPart('p2'), taskPart('x2'), planPart('p'), taskPart('x')] }
|
||||
await render(message)
|
||||
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['true', 'false'] }, 'insertions retain original identity only')
|
||||
await click(plan, 1) // explicitly open original; keep task closed
|
||||
await click(plan, 0) // close new plan; leave new task open
|
||||
message = { ...message, parts: [taskPart('x'), planPart('p'), ...prefix, taskPart('x2'), planPart('p2')] }
|
||||
await render(message)
|
||||
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'true'] }, 'reorder retains independent choices')
|
||||
await click(task, 1)
|
||||
await click(outer); await click(outer)
|
||||
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'false'] }, 'reordered choices survive outer reparenting')
|
||||
await render({ ...message, id: 'native-two' })
|
||||
assert.deepEqual(states(), { plan: ['true', 'true'], task: ['true', 'true'] }, 'same part IDs in another message are isolated')
|
||||
assert.equal(get(outer).getAttribute('aria-expanded'), 'false')
|
||||
|
||||
// Legacy persisted payloads may predate required ID fields. Keep their positional fallback,
|
||||
// without promising identity across arbitrary insertions when no business identity exists.
|
||||
const legacyParts = [planPart(''), taskPart('')].map(part => {
|
||||
const copy = structuredClone(part) as any
|
||||
delete copy[copy.type].id
|
||||
return copy
|
||||
}) as MessagePart[]
|
||||
const legacy = { ...message, id: 'legacy', parts: [...prefix, ...legacyParts] }
|
||||
await render(legacy)
|
||||
await click(plan); await click(task)
|
||||
await render(structuredClone(legacy))
|
||||
await click(outer); await click(outer)
|
||||
assert.deepEqual(states(), collapsed, 'no-ID old history retains choice at the same position')
|
||||
assert.notEqual(completedFoldPartIdentity(legacyParts[0], 1), completedFoldPartIdentity(legacyParts[0], 2), 'no-ID fallback remains positional')
|
||||
await render({ ...legacy, id: 'legacy-other' })
|
||||
assert.deepEqual(states(), { plan: ['true'], task: ['true'] }, 'legacy fallback remains message-isolated')
|
||||
await act(async () => root.unmount()); dom.window.close()
|
||||
console.log('completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed')
|
||||
}
|
||||
main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) })
|
||||
Reference in new issue
Block a user