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
+406
-36
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) })
|
||||||
@@ -24,11 +24,14 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
|
|||||||
|
|
||||||
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
||||||
if (process.env.YCODE_TEST_DOM_MODULE) {
|
if (process.env.YCODE_TEST_DOM_MODULE) {
|
||||||
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) {
|
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) {
|
||||||
const outfile = `.tmp/alignment-tests/${file}.cjs`
|
const outfile = `.tmp/alignment-tests/${file}.cjs`
|
||||||
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
|
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
|
||||||
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
|
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
|
||||||
if (result.error) throw result.error
|
if (result.error) throw result.error
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||||
}
|
}
|
||||||
|
const modeChip = spawnSync(process.execPath, ['scripts/test-mode-chip.mjs'], { stdio: 'inherit', timeout: 120000 })
|
||||||
|
if (modeChip.error) throw modeChip.error
|
||||||
|
if (modeChip.status !== 0) process.exit(modeChip.status ?? 1)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/** Real Chromium fixture, isolated callbacks only: no IPC, sessions, models, or original app. */
|
||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
|
import '../src/renderer/src/workbuddy/typography/tokens.css'
|
||||||
|
import '../src/renderer/src/styles/tokens.css'
|
||||||
|
import '../src/renderer/src/ui/tokens.css'
|
||||||
|
import '../src/renderer/src/styles/app.css'
|
||||||
|
import '../src/renderer/src/styles/sidebar.css'
|
||||||
|
import '../src/renderer/src/workbuddy/conversation/source-styles.css'
|
||||||
|
import '../src/renderer/src/workbuddy/conversation/source-adapters.css'
|
||||||
|
import '../src/renderer/src/components/workspace-source.css'
|
||||||
|
import '../src/renderer/src/components/source-select/source-select-recovered.css'
|
||||||
|
import '../src/renderer/src/components/source-select/source-select.css'
|
||||||
|
import { TaskPopconfirm } from '../src/renderer/src/components/TaskPopconfirm'
|
||||||
|
const root = createRoot(document.getElementById('root')!)
|
||||||
|
const anchor = document.getElementById('anchor')!
|
||||||
|
const results: unknown[] = []
|
||||||
|
const pause = () => new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
function check(actual: unknown, expected: unknown, label: string) {
|
||||||
|
results.push({label, actual, expected})
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label + ': ' + JSON.stringify(actual))
|
||||||
|
}
|
||||||
|
const button = (name: string) => document.querySelector<HTMLButtonElement>('.task-popconfirm__' + name)!
|
||||||
|
async function run() {
|
||||||
|
for (const theme of ['light', 'dark']) for (const kind of ['archive', 'delete'] as const) for (const running of (kind === 'delete' ? [false, true] : [false])) {
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
let calls = 0, closes = 0, resolve!: () => void, reject!: (cause: Error) => void
|
||||||
|
const close = () => { closes++; flushSync(() => root.render(null)) }
|
||||||
|
flushSync(() => root.render(<TaskPopconfirm kind={kind} running={running} anchor={anchor} onClose={close} onConfirm={() => { calls++; return new Promise<void>((yes, no) => { resolve = yes; reject = no }) }} />))
|
||||||
|
await pause()
|
||||||
|
const label = `${theme}/${kind}/${running}`
|
||||||
|
const dark = theme === 'dark'
|
||||||
|
const confirm = button('confirm'), cancel = button('cancel')
|
||||||
|
for (const node of [confirm, cancel]) {
|
||||||
|
const s = getComputedStyle(node)
|
||||||
|
check([s.minWidth, s.paddingLeft, s.paddingRight, s.height, s.borderRadius, s.fontSize], ['66px', '19px', '19px', '28px', '6px', '12px'], label + ' geometry')
|
||||||
|
}
|
||||||
|
const primary = getComputedStyle(confirm), secondary = getComputedStyle(cancel)
|
||||||
|
check([primary.backgroundColor, primary.color], kind === 'delete' ? [dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)', 'rgb(255, 255, 255)'] : dark ? ['rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.9)'] : ['rgba(0, 0, 0, 0.9)', 'rgb(255, 255, 255)'], label + ' primary/danger colors')
|
||||||
|
check([secondary.backgroundColor, secondary.borderTopWidth, secondary.borderTopStyle, secondary.borderTopColor], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', '1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)'], label + ' secondary colors/border')
|
||||||
|
const popup = document.querySelector<HTMLElement>('.task-popconfirm')!
|
||||||
|
check([getComputedStyle(popup).width, getComputedStyle(popup).borderRadius, getComputedStyle(document.querySelector('.task-popconfirm__title')!).fontWeight], ['240px', '16px', '600'], label + ' shell')
|
||||||
|
check(getComputedStyle(popup).backgroundColor, dark ? 'rgb(36, 36, 36)' : 'rgb(255, 255, 255)', label + ' shell background')
|
||||||
|
check(document.activeElement === confirm, true, label + ' initial focus')
|
||||||
|
if (running) check(confirm.textContent, '停止并删除', label + ' running delete semantic')
|
||||||
|
confirm.click(); confirm.click(); await pause()
|
||||||
|
check([calls, confirm.disabled, cancel.disabled, confirm.textContent], [1, true, true, '处理中…'], label + ' busy prevents duplicates')
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); cancel.click()
|
||||||
|
check(closes, 0, label + ' busy cannot dismiss')
|
||||||
|
if (kind === 'delete') check(getComputedStyle(confirm).backgroundColor, dark ? 'rgb(128, 56, 56)' : 'rgb(245, 185, 185)', label + ' danger busy background')
|
||||||
|
reject(new Error('fixture failure')); await pause()
|
||||||
|
check(document.querySelector('[role="alert"]')?.textContent, 'fixture failure', label + ' failure retained')
|
||||||
|
check(confirm.disabled, false, label + ' retry enabled')
|
||||||
|
confirm.click(); await pause(); resolve(); await pause()
|
||||||
|
check([calls, closes, !!document.querySelector('.task-popconfirm')], [2, 1, false], label + ' retry success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run().then(() => finish(true), error => finish(false, String(error)))
|
||||||
|
function finish(pass: boolean, error?: string) {
|
||||||
|
const output = document.createElement('pre'); output.id = 'result'; output.textContent = JSON.stringify({pass, error, results}); document.body.append(output)
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ async function main() {
|
|||||||
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
|
||||||
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
|
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
|
||||||
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
|
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
|
||||||
check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit')
|
check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation')
|
||||||
const confirm = document.querySelector('.task-popconfirm__confirm')!
|
const confirm = document.querySelector('.task-popconfirm__confirm')!
|
||||||
await act(async () => { click(confirm); click(confirm) })
|
await act(async () => { click(confirm); click(confirm) })
|
||||||
check(calls === 1, 'duplicate confirmation guarded synchronously')
|
check(calls === 1, 'duplicate confirmation guarded synchronously')
|
||||||
@@ -56,6 +56,22 @@ async function main() {
|
|||||||
await act(async () => resolve!())
|
await act(async () => resolve!())
|
||||||
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
|
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
|
||||||
await render('delete')
|
await render('delete')
|
||||||
|
await act(async () => root.render(null))
|
||||||
|
await act(async () => root.render(<TaskPopconfirm kind="delete" running anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
|
||||||
|
const runningConfirm = document.querySelector<HTMLButtonElement>('.task-popconfirm__confirm')!
|
||||||
|
const runningCancel = document.querySelector<HTMLButtonElement>('.task-popconfirm__cancel')!
|
||||||
|
check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action')
|
||||||
|
const beforeRunning = calls, beforeRunningClose = closes
|
||||||
|
await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) })
|
||||||
|
check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked')
|
||||||
|
check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled')
|
||||||
|
await act(async () => reject!(new Error('stop failed')))
|
||||||
|
check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained')
|
||||||
|
check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action')
|
||||||
|
await act(async () => click(runningConfirm))
|
||||||
|
await act(async () => resolve!())
|
||||||
|
check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once')
|
||||||
|
await render('delete')
|
||||||
let underlyingClicks = 0
|
let underlyingClicks = 0
|
||||||
other.addEventListener('click', () => underlyingClicks++)
|
other.addEventListener('click', () => underlyingClicks++)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/** Own-YCode isolated component fixture. Run on a permitted Electron desktop:
|
||||||
|
* node scripts/test-mode-chip-native.mjs
|
||||||
|
* No original app, model, SDK, user data, or production session access.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
const require = createRequire(import.meta.url), module = require('electron')
|
||||||
|
const base = path.resolve('.tmp')
|
||||||
|
fs.mkdirSync(base, { recursive: true })
|
||||||
|
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
|
||||||
|
async function prepareFixture(output) {
|
||||||
|
const src = relative => JSON.stringify(path.resolve(relative).replaceAll('\\', '/'))
|
||||||
|
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
|
||||||
|
import React,{useState} from 'react'; import {createRoot} from 'react-dom/client';
|
||||||
|
import {ModeChip} from ${src(file)};
|
||||||
|
import ${src('src/renderer/src/workbuddy/typography/tokens.css')};
|
||||||
|
import ${src('src/renderer/src/styles/tokens.css')};
|
||||||
|
import ${src('src/renderer/src/ui/tokens.css')};
|
||||||
|
import ${src('src/renderer/src/styles/app.css')};
|
||||||
|
import ${src('src/renderer/src/workbuddy/conversation/source-styles.css')};
|
||||||
|
const q=(window as any).qa={clears:0};
|
||||||
|
function Fixture(){const [mode,setMode]=useState<any>('plan'),[disabled,setDisabled]=useState(false);Object.assign(q,{setMode,setDisabled});return <div className="chat-pane cr-theme" style={{padding:40}}><div style={{display:"flex",alignItems:"center",gap:8}}><button id="before">Before</button><ModeChip mode={mode} disabled={disabled} onClear={()=>{q.clears++;setMode('craft')}}/><button id="after">After</button></div></div>};createRoot(document.getElementById('root')!).render(<Fixture/>);`)
|
||||||
|
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
|
||||||
|
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
|
||||||
|
}
|
||||||
|
if (!process.versions.electron) {
|
||||||
|
const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||||
|
if (process.argv.includes('--build-only')) {
|
||||||
|
await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false }))
|
||||||
|
} else {
|
||||||
|
const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
|
||||||
|
const timeout = setTimeout(() => child.kill(), 60000)
|
||||||
|
child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 })
|
||||||
|
child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1))
|
||||||
|
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
|
||||||
|
fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration()
|
||||||
|
let win; const checks = [], samples = [], errors = []
|
||||||
|
try {
|
||||||
|
await prepareFixture(output)
|
||||||
|
await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||||
|
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
|
||||||
|
await win.loadFile(path.join(output, 'index.html')); win.showInactive()
|
||||||
|
const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||||
|
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
|
||||||
|
const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() }
|
||||||
|
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
|
||||||
|
await pause()
|
||||||
|
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
|
||||||
|
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
|
||||||
|
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
|
||||||
|
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
|
||||||
|
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
|
||||||
|
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
|
||||||
|
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
|
||||||
|
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
|
||||||
|
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
|
||||||
|
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
|
||||||
|
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
|
||||||
|
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
|
||||||
|
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
|
||||||
|
}
|
||||||
|
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
|
||||||
|
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
|
||||||
|
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0)
|
||||||
|
} catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** Isolated DOM regression; not a browser layout/pixel or original-app acceptance test. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_PATH || 'jsdom')
|
||||||
|
const output = fs.mkdtempSync(path.resolve('.tmp/mode-chip-dom-'))
|
||||||
|
await build({ entryPoints: ['src/renderer/src/workbuddy/composer/ModeChip.tsx'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', packages: 'external', loader: { '.css': 'empty' }, outfile: path.join(output, 'component.cjs'), logLevel: 'error' })
|
||||||
|
const dom = new JSDOM('<html><head></head><body><div id="root"></div></body></html>', { url: 'http://localhost' })
|
||||||
|
Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })
|
||||||
|
const React = require('react'), { createRoot } = require('react-dom/client')
|
||||||
|
const { ModeChip } = require(path.join(output, 'component.cjs'))
|
||||||
|
const css = fs.readFileSync('src/renderer/src/workbuddy/composer/mode-chip.css', 'utf8')
|
||||||
|
const style = document.createElement('style'); style.textContent = css; document.head.append(style)
|
||||||
|
const root = createRoot(document.getElementById('root')); let clears = 0, checks = 0
|
||||||
|
const check = (actual, expected, message) => { assert.deepEqual(actual, expected, message); checks++ }
|
||||||
|
const render = async (mode, disabled = false) => React.act(async () => root.render(React.createElement(ModeChip, { mode, disabled, onClear: () => { clears++ } })))
|
||||||
|
for (const mode of ['plan', 'ask']) {
|
||||||
|
await render(mode)
|
||||||
|
const chip = document.querySelector('.yc-mode-chip'), slot = chip.querySelector('.yc-mode-chip__icon-slot'), button = slot.querySelector('button'), icon = slot.querySelector('.yc-mode-chip__mode-icon')
|
||||||
|
check(chip.textContent, mode === 'plan' ? '计划' : '仅问答', 'mode label has no extra X text')
|
||||||
|
check(chip.getAttribute('role'), 'group', 'chip remains a group, not a menu')
|
||||||
|
check(button.type, 'button', 'clear cannot submit composer')
|
||||||
|
check(button.getAttribute('aria-label'), `退出${chip.textContent}模式`, 'accessible exit name')
|
||||||
|
check(icon.parentElement === button.parentElement, true, 'icon and close share one slot')
|
||||||
|
check(dom.window.getComputedStyle(slot).display, 'grid', 'shared grid slot')
|
||||||
|
check(dom.window.getComputedStyle(button).gridArea, '1 / 1', 'button occupies original icon cell')
|
||||||
|
check(dom.window.getComputedStyle(icon).gridArea, '1 / 1', 'mode occupies same original cell')
|
||||||
|
check(dom.window.getComputedStyle(button).position, 'static', 'no floating absolute close')
|
||||||
|
check(dom.window.getComputedStyle(chip).backgroundColor, 'rgba(0, 0, 0, 0)', 'transparent background')
|
||||||
|
await React.act(async () => button.focus())
|
||||||
|
check(document.activeElement === button, true, 'native button receives keyboard focus')
|
||||||
|
const before = clears
|
||||||
|
await React.act(async () => button.click())
|
||||||
|
check(clears, before + 1, 'activation calls existing host clear exactly once')
|
||||||
|
await render(mode, true)
|
||||||
|
check(document.querySelector('button'), null, 'disabled mode exposes no clear action')
|
||||||
|
check(document.querySelector('.yc-mode-chip').getAttribute('aria-disabled'), 'true', 'disabled state is announced')
|
||||||
|
check(document.querySelector('.yc-mode-chip').tabIndex, -1, 'disabled mode not in keyboard tab order')
|
||||||
|
}
|
||||||
|
await render('craft'); check(document.querySelector('.yc-mode-chip'), null, 'craft stays hidden')
|
||||||
|
// CSS contracts complement DOM checks; test-mode-chip-native.mjs is an unexecuted native hover/focus/layout probe.
|
||||||
|
for (const contract of ['border-radius:var(--yc-radius-full)', 'color:var(--cr-text-secondary)', ':hover .yc-mode-chip__mode-icon', ':focus-within .yc-mode-chip__mode-icon', ':hover .yc-mode-chip__clear', ':focus-within .yc-mode-chip__clear']) check(css.includes(contract), true, contract)
|
||||||
|
await React.act(async () => root.unmount()); dom.window.close()
|
||||||
|
console.log(JSON.stringify({ checks, passed: true, scope: 'Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance' }))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Build and run real-browser computed-style checks; no jsdom style substitute.
|
||||||
|
* CHROMIUM_PATH=/path/to/chrome node scripts/test-task-popconfirm-browser.mjs
|
||||||
|
* --build-only creates a portable HTML fixture for an available browser.
|
||||||
|
* Chromium sandbox remains enabled. Runtime failure is NOT a passing test.
|
||||||
|
*/
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { mkdtemp, writeFile, readFile } from 'node:fs/promises'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
const dir = await mkdtemp(resolve('.tmp/task-popconfirm-browser-'))
|
||||||
|
await build({ entryPoints: ['scripts/task-popconfirm-browser-fixture.tsx'], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: dir + '/fixture.js', loader: {'.svg':'dataurl', '.woff2':'dataurl', '.woff':'dataurl'}, logLevel: 'warning' })
|
||||||
|
const js = await readFile(dir + '/fixture.js', 'utf8'), css = await readFile(dir + '/fixture.css', 'utf8')
|
||||||
|
await writeFile(dir + '/index.html', `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body><button id="anchor" style="position:absolute;left:30px;top:100px">Task</button><main id="root"></main><script>${js.replaceAll('</script', '<\\/script')}</script></body></html>`)
|
||||||
|
console.log('Browser fixture: ' + dir + '/index.html')
|
||||||
|
if (!process.argv.includes('--build-only')) {
|
||||||
|
const proc = spawnSync(process.env.CHROMIUM_PATH || 'chromium', ['--headless', '--disable-gpu', '--no-first-run', '--user-data-dir=' + dir + '/profile', '--virtual-time-budget=20000', '--dump-dom', pathToFileURL(dir + '/index.html').href], { encoding:'utf8', timeout:60000, maxBuffer:20*1024*1024 })
|
||||||
|
await writeFile(dir + '/browser.log', (proc.stderr || '') + '\n' + (proc.error || ''))
|
||||||
|
const match = proc.stdout?.match(/<pre id="result">([\s\S]*?)<\/pre>/)
|
||||||
|
if (proc.status !== 0 || !match) throw Error(`Browser did not finish; status=${proc.status}; see ${dir}/browser.log`)
|
||||||
|
const report = JSON.parse(match[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&'))
|
||||||
|
await writeFile(dir + '/report.json', JSON.stringify(report, null, 2))
|
||||||
|
if (!report.pass) throw Error(report.error)
|
||||||
|
console.log(`Task confirmation real Chromium: ${report.results.length} assertions passed; ${dir}/report.json`)
|
||||||
|
}
|
||||||
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
|
|||||||
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||||||
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
||||||
}
|
}
|
||||||
|
// Assert styles in the real body portal, not just variant class names.
|
||||||
|
async function checkConfirmationStyles(kind) {
|
||||||
|
const previous = await read('document.documentElement.dataset.theme || "light"')
|
||||||
|
const setTheme = async theme => {
|
||||||
|
await read(`(()=>{localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))})()`)
|
||||||
|
await wait(`document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 'application theme synchronization')
|
||||||
|
await pause(400)
|
||||||
|
}
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await setTheme(theme)
|
||||||
|
const values = await read(`(()=>{const style=name=>{const s=getComputedStyle(document.querySelector('.task-popconfirm__'+name));return {background:s.backgroundColor,color:s.color,border:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],geometry:[s.minWidth,s.paddingLeft,s.paddingRight,s.height,s.borderRadius,s.fontSize]}};return {cancel:style('cancel'),confirm:style('confirm'),titleWeight:getComputedStyle(document.querySelector('.task-popconfirm__title')).fontWeight}})()`)
|
||||||
|
const dark = theme === 'dark'
|
||||||
|
check(kind+' '+theme+' secondary background/border', [values.cancel.background, values.cancel.border], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', ['1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)']])
|
||||||
|
check(kind+' '+theme+' primary/danger background', values.confirm.background, kind === 'delete' ? (dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)') : (dark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'))
|
||||||
|
check(kind+' '+theme+' confirmation foreground', values.confirm.color, kind === 'archive' && dark ? 'rgba(0, 0, 0, 0.9)' : 'rgb(255, 255, 255)')
|
||||||
|
for (const action of ['confirm', 'cancel']) check(kind+' '+theme+' '+action+' geometry', values[action].geometry, ['66px', '19px', '19px', '28px', '6px', '12px'])
|
||||||
|
check(kind+' '+theme+' title weight', values.titleWeight, '600')
|
||||||
|
await shot(kind+'-confirm-'+theme)
|
||||||
|
}
|
||||||
|
await setTheme(previous)
|
||||||
|
}
|
||||||
// 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
|
// 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
|
||||||
const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
|
const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
|
||||||
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
|
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
|
||||||
@@ -167,9 +188,10 @@ if (!process.versions.electron) {
|
|||||||
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
|
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
|
||||||
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
|
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
|
||||||
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
|
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
|
||||||
check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
|
check('archive does not append unrequested compatibility copy', await read(`!!document.querySelector('.task-popconfirm__compatibility')`), false)
|
||||||
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
|
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
|
||||||
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
|
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '16px'])
|
||||||
|
await checkConfirmationStyles('archive')
|
||||||
await shot('archive-confirm')
|
await shot('archive-confirm')
|
||||||
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
||||||
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
|
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
|
||||||
@@ -195,6 +217,7 @@ if (!process.versions.electron) {
|
|||||||
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
|
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
|
||||||
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
|
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
|
||||||
check('idle delete is not marked as running', deleteConfirm.running, null)
|
check('idle delete is not marked as running', deleteConfirm.running, null)
|
||||||
|
await checkConfirmationStyles('delete')
|
||||||
await shot('delete-confirm')
|
await shot('delete-confirm')
|
||||||
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
|
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
|
||||||
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
|
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
import { Popover } from '../ui/Popover'
|
import { Popover } from '../ui/Popover'
|
||||||
import './task-popconfirm.css'
|
import './task-popconfirm.css'
|
||||||
|
|
||||||
@@ -49,12 +50,11 @@ export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: Ta
|
|||||||
<div aria-busy={busy} data-task-confirm-kind={kind} data-task-confirm-running={running || undefined}>
|
<div aria-busy={busy} data-task-confirm-kind={kind} data-task-confirm-running={running || undefined}>
|
||||||
<h2 className="task-popconfirm__title" id={titleId}>{copy.title}</h2>
|
<h2 className="task-popconfirm__title" id={titleId}>{copy.title}</h2>
|
||||||
<p className="task-popconfirm__description" id={descriptionId}>{copy.description}</p>
|
<p className="task-popconfirm__description" id={descriptionId}>{copy.description}</p>
|
||||||
{kind === 'archive' && <p className="task-popconfirm__compatibility">YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。</p>}
|
|
||||||
{error && <div className="task-popconfirm__error" id={errorId} role="alert">{error}</div>}
|
{error && <div className="task-popconfirm__error" id={errorId} role="alert">{error}</div>}
|
||||||
<div className="task-popconfirm__actions">
|
<div className="task-popconfirm__actions">
|
||||||
<button type="button" className="wb-button wb-button--secondary wb-button--small task-popconfirm__cancel" disabled={busy} onClick={close}>取消</button>
|
<Button variant="secondary" size="sm" className="task-popconfirm__cancel" disabled={busy} onClick={close}>取消</Button>
|
||||||
<button type="button" className={`wb-button wb-button--primary wb-button--small task-popconfirm__confirm${kind === 'delete' ? ' wb-button--danger' : ''}`}
|
<Button variant={kind === 'delete' ? 'danger' : 'primary'} size="sm" className="task-popconfirm__confirm"
|
||||||
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</button>
|
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
width: calc(var(--yc-space-10) * 5);
|
width: calc(var(--yc-space-10) * 5);
|
||||||
max-width: calc(100vw - var(--yc-space-5));
|
max-width: calc(100vw - var(--yc-space-5));
|
||||||
padding: var(--yc-space-5);
|
padding: var(--yc-space-5);
|
||||||
border-radius: var(--yc-radius-xl);
|
border-radius: var(--yc-radius-2xl);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
color: var(--wb-color-text-primary);
|
color: var(--wb-color-text-primary);
|
||||||
background: var(--wb-bg-popover);
|
background: var(--wb-bg-popover);
|
||||||
@@ -14,17 +14,20 @@
|
|||||||
margin: 0 0 var(--yc-space-3);
|
margin: 0 0 var(--yc-space-3);
|
||||||
font-size: var(--yc-font-size-5);
|
font-size: var(--yc-font-size-5);
|
||||||
line-height: var(--yc-line-height-5-2);
|
line-height: var(--yc-line-height-5-2);
|
||||||
font-weight: 500;
|
font-weight: var(--yc-font-weight-600);
|
||||||
}
|
}
|
||||||
.task-popconfirm__description,
|
.task-popconfirm__description {
|
||||||
.task-popconfirm__compatibility {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--wb-color-text-secondary);
|
color: var(--wb-color-text-secondary);
|
||||||
font-size: var(--yc-font-size-4);
|
font-size: var(--yc-font-size-4);
|
||||||
line-height: var(--yc-line-height-5);
|
line-height: var(--yc-line-height-5);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.task-popconfirm__compatibility { margin-top: var(--yc-space-3); color: var(--wb-color-text-tertiary); }
|
|
||||||
.task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); }
|
.task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); }
|
||||||
.task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); }
|
.task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); }
|
||||||
.task-popconfirm-source .task-popconfirm__actions .wb-button { min-width: calc(var(--yc-space-10) + var(--yc-space-2)); height: var(--yc-line-height-7); padding: 0 var(--yc-space-3); }
|
.task-popconfirm-source .task-popconfirm__actions .yc-button {
|
||||||
|
/* Recorded minimum width and inset, composed from existing tokens. */
|
||||||
|
min-width: calc(var(--yc-size-17) - var(--yc-border-width) * 2);
|
||||||
|
height: var(--yc-line-height-7);
|
||||||
|
padding: 0 calc(var(--yc-space-6) - var(--yc-border-width));
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ import './mode-chip.css'
|
|||||||
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise<void> }): React.JSX.Element | null {
|
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise<void> }): React.JSX.Element | null {
|
||||||
if (mode === 'craft') return null
|
if (mode === 'craft') return null
|
||||||
const label = mode === 'plan' ? '计划' : '仅问答'
|
const label = mode === 'plan' ? '计划' : '仅问答'
|
||||||
return <span className="yc-mode-chip" role="group" tabIndex={0} aria-label={`${label}模式`}>
|
return <span className="yc-mode-chip" role="group" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} aria-label={`${label}模式`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
|
<span className="yc-mode-chip__icon-slot">
|
||||||
|
<svg className="yc-mode-chip__mode-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
|
||||||
{mode === 'plan' ? <><rect x="3" y="3" width="10" height="11" rx="1.5" /><rect x="5.5" y="1.5" width="5" height="3" rx="1" /><path d="M5.5 7h5M5.5 10h5" /></> : <path d="M13.5 7.5a5.5 5.5 0 0 1-5.5 5.5H3l-1.5 1v-6.5A5.5 5.5 0 0 1 7 2h1a5.5 5.5 0 0 1 5.5 5.5Z" />}
|
{mode === 'plan' ? <><rect x="3" y="3" width="10" height="11" rx="1.5" /><rect x="5.5" y="1.5" width="5" height="3" rx="1" /><path d="M5.5 7h5M5.5 10h5" /></> : <path d="M13.5 7.5a5.5 5.5 0 0 1-5.5 5.5H3l-1.5 1v-6.5A5.5 5.5 0 0 1 7 2h1a5.5 5.5 0 0 1 5.5 5.5Z" />}
|
||||||
</svg><span>{label}</span>
|
</svg>
|
||||||
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>×</button>}
|
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8" /></svg>
|
||||||
|
</button>}
|
||||||
|
</span><span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
.yc-mode-chip { position:relative; display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-space-3); background:var(--cr-bg-tertiary); color:var(--cr-text-primary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; }
|
.yc-mode-chip { display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-radius-full); background:transparent; color:var(--cr-text-secondary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; }
|
||||||
.yc-mode-chip > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
|
.yc-mode-chip__icon-slot { display:grid; flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
|
||||||
|
.yc-mode-chip__mode-icon, .yc-mode-chip__clear { grid-area:1 / 1; width:var(--yc-space-5); height:var(--yc-space-5); }
|
||||||
|
.yc-mode-chip__clear > svg { width:100%; height:100%; }
|
||||||
.yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); }
|
.yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); }
|
||||||
.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; width:var(--yc-space-5); height:var(--yc-space-5); padding:0; border:0; border-radius:50%; background:var(--cr-bg-tertiary); color:inherit; font-size:var(--yc-space-5); cursor:pointer; opacity:0; position:absolute; right:calc(-1 * var(--wb-spacing-2-5)); top:calc(-1.5 * var(--yc-space-1)); }
|
.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:var(--yc-radius-full); background:transparent; color:inherit; cursor:pointer; opacity:0; }
|
||||||
.yc-mode-chip:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; }
|
.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__mode-icon, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__mode-icon { opacity:0; }
|
||||||
|
.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__clear, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__clear { opacity:1; }
|
||||||
.yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); }
|
.yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); }
|
||||||
@@ -4,6 +4,7 @@ import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
|
|||||||
import {
|
import {
|
||||||
buildCompletedFoldModel,
|
buildCompletedFoldModel,
|
||||||
classifyCompletedFoldPart,
|
classifyCompletedFoldPart,
|
||||||
|
completedFoldPartIdentity,
|
||||||
type CompletedFoldOptions,
|
type CompletedFoldOptions,
|
||||||
type CompletedFoldPart,
|
type CompletedFoldPart,
|
||||||
type CompletedFoldSegment,
|
type CompletedFoldSegment,
|
||||||
@@ -26,7 +27,7 @@ function renderPart(
|
|||||||
summaryGroup?: CompletedFoldSummaryGroup
|
summaryGroup?: CompletedFoldSummaryGroup
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
|
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
|
||||||
return <DisclosureStateScope key={unit.key} id={partDisclosureId(unit.part, unit.index)}>{render(unit.part, unit.index, {
|
return <DisclosureStateScope key={unit.key} id={completedFoldPartIdentity(unit.part, unit.index)}>{render(unit.part, unit.index, {
|
||||||
kind: summaryGroup ? 'summary-member' : 'part',
|
kind: summaryGroup ? 'summary-member' : 'part',
|
||||||
classification,
|
classification,
|
||||||
presentationGroupId: classification.presentationGroupId,
|
presentationGroupId: classification.presentationGroupId,
|
||||||
@@ -42,7 +43,7 @@ function renderPresentationUnit(
|
|||||||
if (unit.kind === 'part') return renderPart(unit.unit, render)
|
if (unit.kind === 'part') return renderPart(unit.unit, render)
|
||||||
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
|
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
|
||||||
return (
|
return (
|
||||||
<DisclosureStateScope key={unit.group.key} id={`summary:${partDisclosureId(unit.group.units[0].part, unit.group.units[0].index)}`}>
|
<DisclosureStateScope key={unit.group.key} id={`summary:${completedFoldPartIdentity(unit.group.units[0].part, unit.group.units[0].index)}`}>
|
||||||
{renderGroup ? renderGroup(unit.group, children) : <>{children}</>}
|
{renderGroup ? renderGroup(unit.group, children) : <>{children}</>}
|
||||||
</DisclosureStateScope>
|
</DisclosureStateScope>
|
||||||
)
|
)
|
||||||
@@ -146,10 +147,6 @@ export function LiveEventDisclosure({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function partDisclosureId(part: CompletedFoldPart, index: number): string {
|
|
||||||
const id = 'id' in part && typeof part.id === 'string' ? part.id : part.type === 'tool' && typeof part.toolId === 'string' ? part.toolId : index
|
|
||||||
return JSON.stringify([part.type, id])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompletedFold(props: CompletedFoldProps) {
|
export function CompletedFold(props: CompletedFoldProps) {
|
||||||
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
|
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
|
||||||
@@ -183,7 +180,7 @@ function CompletedFoldContent({
|
|||||||
if (!model) return <>{parts.map((part, index) => {
|
if (!model) return <>{parts.map((part, index) => {
|
||||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||||
if (!classification.hasRootBlock) return null
|
if (!classification.hasRootBlock) return null
|
||||||
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, {
|
return <DisclosureStateScope key={completedFoldPartIdentity(part, index)} id={completedFoldPartIdentity(part, index)}>{render(part, index, {
|
||||||
kind: 'part',
|
kind: 'part',
|
||||||
classification,
|
classification,
|
||||||
presentationGroupId: classification.presentationGroupId
|
presentationGroupId: classification.presentationGroupId
|
||||||
@@ -208,7 +205,7 @@ function CompletedFoldContent({
|
|||||||
) : parts.map((part, index) => {
|
) : parts.map((part, index) => {
|
||||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||||
if (!classification.hasRootBlock) return null
|
if (!classification.hasRootBlock) return null
|
||||||
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, { kind: 'part', classification })}</DisclosureStateScope>
|
return <DisclosureStateScope key={completedFoldPartIdentity(part, index)} id={completedFoldPartIdentity(part, index)}>{render(part, index, { kind: 'part', classification })}</DisclosureStateScope>
|
||||||
})
|
})
|
||||||
})()}
|
})()}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string {
|
|||||||
return `${seconds}s`
|
return `${seconds}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Business identity survives terminal text coalescing and presentation reordering.
|
||||||
|
* Old histories without an ID intentionally retain positional fallback semantics.
|
||||||
|
*/
|
||||||
|
export function completedFoldPartIdentity(part: CompletedFoldPart, index: number): string {
|
||||||
|
let id: unknown = 'id' in part ? part.id : undefined
|
||||||
|
if (part.type === 'tool') id = part.toolId
|
||||||
|
let content: unknown
|
||||||
|
if (part.type === 'plan') content = part.plan
|
||||||
|
if (part.type === 'task') content = part.task
|
||||||
|
if (part.type === 'artifact') content = part.artifact
|
||||||
|
if (content && typeof content === 'object' && 'id' in content) id = content.id
|
||||||
|
return typeof id === 'string' && id.length > 0
|
||||||
|
? JSON.stringify([part.type, 'id', id])
|
||||||
|
: JSON.stringify([part.type, 'index', index])
|
||||||
|
}
|
||||||
|
|
||||||
function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit {
|
function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit {
|
||||||
const partId = 'id' in part && typeof part.id === 'string'
|
return { key: completedFoldPartIdentity(part, index), index, part }
|
||||||
? part.id
|
|
||||||
: part.type === 'tool' && typeof part.toolId === 'string'
|
|
||||||
? part.toolId
|
|
||||||
: String(index)
|
|
||||||
return { key: `${part.type}:${partId}:${index}`, index, part }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSourceSegments(
|
function buildSourceSegments(
|
||||||
|
|||||||
Reference in new issue
Block a user