fix(B01): retain native card state and restore mode and confirmation styles

This commit is contained in:
Codex committed 2026-10-21 13:41:18 +09:00
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) })