diff --git a/scripts/completed-fold-native-identity-regression.tsx b/scripts/completed-fold-native-identity-regression.tsx new file mode 100644 index 0000000..886b4ce --- /dev/null +++ b/scripts/completed-fold-native-identity-regression.tsx @@ -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('
', { 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(([\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`)
+}
diff --git a/scripts/verify-task-menu.cjs b/scripts/verify-task-menu.cjs
index 9620f73..2513072 100644
--- a/scripts/verify-task-menu.cjs
+++ b/scripts/verify-task-menu.cjs
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
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'))
}
+ // 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 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}})()`)
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
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 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 read(`document.querySelector('.task-popconfirm__cancel').click()`)
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 confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
check('idle delete is not marked as running', deleteConfirm.running, null)
+ await checkConfirmationStyles('delete')
await shot('delete-confirm')
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')
diff --git a/src/renderer/src/components/TaskPopconfirm.tsx b/src/renderer/src/components/TaskPopconfirm.tsx
index 685e2a4..10be730 100644
--- a/src/renderer/src/components/TaskPopconfirm.tsx
+++ b/src/renderer/src/components/TaskPopconfirm.tsx
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState } from 'react'
+import { Button } from '../ui/Button'
import { Popover } from '../ui/Popover'
import './task-popconfirm.css'
@@ -49,12 +50,11 @@ export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: Ta
{copy.title}
{copy.description}
- {kind === 'archive' && YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。
}
{error && {error}}
-
-
+
+
diff --git a/src/renderer/src/components/task-popconfirm.css b/src/renderer/src/components/task-popconfirm.css
index af0e8a7..4da600f 100644
--- a/src/renderer/src/components/task-popconfirm.css
+++ b/src/renderer/src/components/task-popconfirm.css
@@ -4,7 +4,7 @@
width: calc(var(--yc-space-10) * 5);
max-width: calc(100vw - var(--yc-space-5));
padding: var(--yc-space-5);
- border-radius: var(--yc-radius-xl);
+ border-radius: var(--yc-radius-2xl);
overflow: visible;
color: var(--wb-color-text-primary);
background: var(--wb-bg-popover);
@@ -14,17 +14,20 @@
margin: 0 0 var(--yc-space-3);
font-size: var(--yc-font-size-5);
line-height: var(--yc-line-height-5-2);
- font-weight: 500;
+ font-weight: var(--yc-font-weight-600);
}
-.task-popconfirm__description,
-.task-popconfirm__compatibility {
+.task-popconfirm__description {
margin: 0;
color: var(--wb-color-text-secondary);
font-size: var(--yc-font-size-4);
line-height: var(--yc-line-height-5);
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__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));
+}
diff --git a/src/renderer/src/workbuddy/composer/ModeChip.tsx b/src/renderer/src/workbuddy/composer/ModeChip.tsx
index 36435f6..f21782a 100644
--- a/src/renderer/src/workbuddy/composer/ModeChip.tsx
+++ b/src/renderer/src/workbuddy/composer/ModeChip.tsx
@@ -5,10 +5,14 @@ import './mode-chip.css'
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise }): React.JSX.Element | null {
if (mode === 'craft') return null
const label = mode === 'plan' ? '计划' : '仅问答'
- return
- {label}
- {!disabled && }
+ return
+
+
+ {!disabled && }
+ {label}
}
diff --git a/src/renderer/src/workbuddy/composer/mode-chip.css b/src/renderer/src/workbuddy/composer/mode-chip.css
index 838c12e..6f2432c 100644
--- a/src/renderer/src/workbuddy/composer/mode-chip.css
+++ b/src/renderer/src/workbuddy/composer/mode-chip.css
@@ -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 > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
+.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__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__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:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity: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: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); }
diff --git a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx
index 50f1252..0568e63 100644
--- a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx
+++ b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx
@@ -4,6 +4,7 @@ import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
import {
buildCompletedFoldModel,
classifyCompletedFoldPart,
+ completedFoldPartIdentity,
type CompletedFoldOptions,
type CompletedFoldPart,
type CompletedFoldSegment,
@@ -26,7 +27,7 @@ function renderPart(
summaryGroup?: CompletedFoldSummaryGroup
): ReactNode {
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
- return {render(unit.part, unit.index, {
+ return {render(unit.part, unit.index, {
kind: summaryGroup ? 'summary-member' : 'part',
classification,
presentationGroupId: classification.presentationGroupId,
@@ -42,7 +43,7 @@ function renderPresentationUnit(
if (unit.kind === 'part') return renderPart(unit.unit, render)
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
return (
-
+
{renderGroup ? renderGroup(unit.group, children) : <>{children}>}
)
@@ -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) {
return
@@ -183,7 +180,7 @@ function CompletedFoldContent({
if (!model) return <>{parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
- return {render(part, index, {
+ return {render(part, index, {
kind: 'part',
classification,
presentationGroupId: classification.presentationGroupId
@@ -208,7 +205,7 @@ function CompletedFoldContent({
) : parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
- return {render(part, index, { kind: 'part', classification })}
+ return {render(part, index, { kind: 'part', classification })}
})
})()}
diff --git a/src/renderer/src/workbuddy/conversation/completed-fold.ts b/src/renderer/src/workbuddy/conversation/completed-fold.ts
index 65e8336..8218432 100644
--- a/src/renderer/src/workbuddy/conversation/completed-fold.ts
+++ b/src/renderer/src/workbuddy/conversation/completed-fold.ts
@@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string {
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 {
- const partId = 'id' in part && typeof part.id === 'string'
- ? part.id
- : part.type === 'tool' && typeof part.toolId === 'string'
- ? part.toolId
- : String(index)
- return { key: `${part.type}:${partId}:${index}`, index, part }
+ return { key: completedFoldPartIdentity(part, index), index, part }
}
function buildSourceSegments(