fix: locate note entries after their content mounts

Quick Notes could open the full workspace before its selected record was mounted, losing the scroll request. Perform entry navigation after React commits and retain a deterministic regression. Align the manual-compaction fixture with layered model selection and loaded conversation state.

Release note: 从快速笔记打开完整记录时,等待目标内容挂载后再定位,避免偶发只打开页面却未滚动到所选记录。
This commit is contained in:
mesalogo
2026-09-30 19:46:13 +08:00
parent 89fc100d4f
commit 3aa6c9a886
7 changed files with 41 additions and 12 deletions
@@ -65,3 +65,10 @@ Agent daemon 和协议不变,Agent 仍为 0.15.1。
类型检查、lint、双语说明校验和差异检查通过。该轮测试使用隔离 fixture,
没有新增付费模型调用;先前 4 次真实 Host 请求仅对应取消后续聊范围,
不作为新增标签工具的完整远程 UI 实测声明。
`89fc100` 的 Agent CI 通过;Desktop `36707706849` 为 5507 通过、55 跳过、
2 失败,前述三个失败场景均已通过。笔记滚动在请求完成回调中早于 DOM 提交,
以立即执行动画帧的回归稳定复现后,改在布局 Effect 中定位已挂载记录。
手动压缩独立复跑通过,夹具同步当前分层选择格式并等待目标会话历史显示;
尚不将独立通过视为 Linux 全仓通过。修正后完整 App/笔记组件及真实 Electron
导航/压缩共 450 项通过,类型检查和 lint 通过,无新增外部模型调用。
+8
View File
@@ -1,5 +1,13 @@
# Magic Notes Progress
## 2026-09-30: Navigate After Record Mount
Quick Notes navigation now scrolls after React commits the target record, rather
than scheduling from the data request's completion. A regression that runs the
animation-frame callback immediately fails before this fix and passes afterward.
The complete App/workspace tests and Electron navigation/compaction checks pass:
450 tests across four files, plus typecheck and lint. No provider calls were made.
## 2026-09-30: Manual Note Tags
Implemented FR-7 to FR-10 as described in
+3
View File
@@ -64,6 +64,9 @@ Runtime 状态尚未匹配当前选择时按钮仍禁用,点击不会发出请
2026-09-30 发布验证中,连续新建会话用例只等 Runtime mock 收到请求,尚未等
已用会话渲染完成就触发新建。沿用 `waitFor` 等待已发送用户消息可见,再保持
两次同批新建及最终数量断言;mock 调用不代表界面状态已提交。
同日 CI 再现快速笔记打开完整记录后未滚动:数据请求完成后的动画帧仍可能早于
React 提交目标 DOM。定位操作应在目标记录提交后的布局 Effect 中执行,
而不是在请求 Promise 完成时猜测渲染时机;立即执行动画帧的回归可稳定暴露旧问题。
## 整窗遮罩与原生拖动区域
+2
View File
@@ -14,6 +14,7 @@
"**手动回顾默认只处理新增与变化内容**:点击“回顾”会沿用处理进度,无变化时不再调用模型;需要重读所选区间时,可从“更多”选择“重新整理…”并确认。重新整理不会推进或重置共享进度,自动回顾也不会续跑用户主动暂停的运行。"
],
"fixes": [
"**从快速笔记打开记录时可靠定位**:进入完整工作区后,等待目标记录挂载再滚动到对应内容。避免加载较慢时页面已打开却没有定位到所选记录。",
"**再次在终端中打开 OpenCode 更快**:插件依赖改为在用户数据目录中缓存复用,不再每次启动都复制完整依赖树;随包版本变化后会更新缓存。首次准备和 OpenCode 自身启动仍需时间,模型配置、Skills 和 Ask 模式插件仍按每次启动生成。",
"**减少 OpenCode 工具名称过长导致的失败**:缩短临时 MCP 名称及转发的自定义工具名称,并在同一会话中尽量保持稳定。并行请求仍使用独立占用的名称,不改变工具权限或执行范围。",
"**滚动文件列表时保留工作区操作栏**:文件列表独立滚动,视图切换、刷新、新建和目录导航保持可见。没有 Git 的项目也保留“文件”入口。",
@@ -35,6 +36,7 @@
"**Manual review defaults to new and changed content**: Review resumes from saved processing progress and makes no model calls when nothing has changed; choose Reanalyze from More and confirm to reread the selected period. Reanalysis neither advances nor resets shared progress, and automatic reviews no longer resume runs you explicitly paused."
],
"fixes": [
"**Reliably locate records opened from Quick Notes**: The full workspace now waits until the target record is mounted before scrolling to it. This avoids opening the page without locating the selected record when rendering is delayed.",
"**Reopen OpenCode in the terminal faster**: Plugin dependencies are cached under user data instead of copying the entire tree on every launch, and refreshed when the bundled version changes. Initial preparation and OpenCode startup still take time; model settings, Skills, and Ask-mode plugins remain launch-specific.",
"**Reduce failures from long OpenCode tool names**: Temporary MCP names and forwarded custom tool names are shorter and remain stable within a conversation where possible. Parallel requests retain separately leased names, with no changes to tool permissions or execution scope.",
"**Keep workspace controls visible while scrolling**: The file list scrolls independently while view selection, refresh, creation actions, and directory navigation stay visible. Projects without Git retain the Files entry.",
+5
View File
@@ -14254,6 +14254,10 @@ describe("App", () => {
const originalScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 0
})
try {
fireEvent.click(within(added).getByRole('button', { name: '在完整工作区打开' }))
await waitFor(() => {
@@ -14261,6 +14265,7 @@ describe("App", () => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' })
})
} finally {
animationFrame.mockRestore()
if (originalScroll) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScroll)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
+13 -10
View File
@@ -707,6 +707,18 @@ export function MagicNotesWorkspace({
return () => cancelAnimationFrame(frame)
}, [detail?.id, detailView])
useLayoutEffect(() => {
const entryId = requestedEntryIdRef.current
if (!entryId || detail?.id !== requestedNoteIdRef.current || detailView !== 'notes') return
const frame = requestAnimationFrame(() => {
const entry = document.getElementById(`magic-note-entry-${entryId}`)
if (!entry) return
entry.scrollIntoView({ block: 'center' })
requestedEntryIdRef.current = undefined
})
return () => cancelAnimationFrame(frame)
}, [detail, detailView])
useEffect(() => {
const layout = todoLayoutRef.current
if (!layout) return
@@ -1168,16 +1180,7 @@ export function MagicNotesWorkspace({
if (libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
setDetailView('notes')
focusSwitchTarget(target)
void loadDetail(target.noteId, target.entryId).then(() => {
if (!target.entryId || requestedNoteIdRef.current !== target.noteId) {
return
}
requestAnimationFrame(() =>
document
.getElementById(`magic-note-entry-${target.entryId}`)
?.scrollIntoView({ block: 'center' })
)
})
void loadDetail(target.noteId, target.entryId)
},
[
createNote,
+3 -2
View File
@@ -48,7 +48,7 @@ void app.whenReady().then(async () => {
database.initialize(directory)
const project = database.listProjects()[0]!
const header = { id: randomUUID(), title: 'Manual compaction', projectId: project.id,
runtimeSelection: { provider: 'model' as const, profileId }, updatedAt: Date.now() }
runtimeSelection: { provider: 'model' as const, model: { kind: 'profile' as const, profileId } }, updatedAt: Date.now() }
const messages = ['Remember the project codename is Cedar.', 'Codename recorded.', 'Meeting is Tuesday.', 'Tuesday recorded.']
.map((content, index) => ({ id: randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const,
content, state: 'complete' as const, createdAt: Date.now() + index }))
@@ -73,6 +73,7 @@ void app.whenReady().then(async () => {
}
try {
await win.loadURL(process.env.GB_COMPACT_URL!)
await wait(() => run(`document.body.innerText.includes('Tuesday recorded.')`), 'selected conversation history rendered')
const button = `[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Compact context')`
await wait(() => run(`Boolean(${button}) && !${button}.disabled`), 'manual button with auto disabled')
await run(`${button}.click()`)
@@ -94,7 +95,7 @@ void app.whenReady().then(async () => {
const next = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!)
try {
for await (const event of next.run({ requestId: randomUUID(), conversationId: header.id,
runtimeSelection: header.runtimeSelection, prompt: 'What is the codename?',
runtimeSelection: { provider: 'model', profileId }, prompt: 'What is the codename?',
history: messages.map(({ role, content }) => ({ role, content })), historyMessageIds: messages.map(({ id }) => id),
contextCompressionState: restored.contextCompressionState
}, AbortSignal.timeout(10000))) {