From d3424a4a74e8f215168843e82ea4cf0ed6e70709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Tue, 31 Mar 2026 08:40:17 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E3=80=81=E6=96=87=E4=BB=B6=E5=90=8C=E6=AD=A5=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E3=80=81Agent=20Diff=20View=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段一:安全加固 - 修复 load-theme-css 路径遍历漏洞(resolveThemePath 校验) - CSP 补强:添加 script-src/object-src/base-uri/frame-src 指令 - IPC 输入校验:open-file-path/save-file/export-html 类型检查 - HTML 导出 sanitization:过滤 script/iframe/on* 事件属性 - html-view sanitization:白名单标签 + 移除危险属性 阶段二:文件同步健壮性 - fs.watch 处理 rename 事件(支持原子保存的编辑器) - watcher error 事件监听 + 自动重建 - stopWatching 清理 debounceTimer - 提取 readTextDocument 统一文件读取 - 添加 MAX_OPEN_FILE_SIZE 5MB 限制 - 提取 Agent 检测常量:AGENT_ACTIVE_GAP_MS 等 阶段三:Agent Diff View - 节点级 diff:替换前快照 → 替换后比较 → 绿色高亮变更节点 - 5 秒自动淡出动画 - setMarkdown(content, showDiff) 新参数 阶段四:代码质量 - preload 事件返回 Unsubscribe 函数,支持注销 - 删除未使用的 scanCustomThemes 函数 - CI release.yml 添加 tsc 类型检查步骤 Co-Authored-By: Claude --- .github/workflows/release.yml | 3 + .gitignore | 4 + CLAUDE.md | 121 ++++++++++++++++++------------- src/main/index.ts | 72 +++++++++++++----- src/preload/index.ts | 80 ++++++++------------ src/renderer/editor/editor.ts | 65 ++++++++++++++++- src/renderer/editor/html-view.ts | 36 ++++++++- src/renderer/index.html | 2 +- src/renderer/main.ts | 22 +++++- src/renderer/themes/base.css | 2 + 10 files changed, 282 insertions(+), 125 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c874782..a83408d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,9 @@ jobs: - run: npm install + - name: Type check + run: node node_modules/typescript/bin/tsc -p tsconfig.main.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.preload.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.renderer.json --noEmit + - run: npm run build - name: Build distributables diff --git a/.gitignore b/.gitignore index c9b4a37..90357ee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ release/ .claude/ *.env log.md + +# Agent Loop logs (迭代日志,不提交到仓库) +agent-loop-logs/ +agent-loop.yaml diff --git a/CLAUDE.md b/CLAUDE.md index a375f95..5de5a19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,68 +1,89 @@ -# ColaMD +# CLAUDE.md -## 产品定位 +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -**Agent Native 的 Markdown 编辑器。** +## Build & Development Commands -面向 Agent-to-Agent、Agent-to-Human 之间的新型协作方式。当前 v1.0 聚焦于核心体验:AI Agent 修改 .md 文件时,ColaMD 自动检测变化并实时显示最新内容,让人类和 Agent 的协作像结对编程一样流畅。 +```bash +npm run dev # Start dev server with HMR (electron-vite dev) +npm run build # Compile all 3 bundles (main, preload, renderer) +npm run preview # Preview production build +npm run dist # Build + package for current platform +npm run dist:mac # macOS build (.dmg + .zip) +npm run dist:win # Windows build (.exe NSIS installer) +npm run dist:linux # Linux build (.AppImage + .deb) +``` + +No test framework, linter, or formatter is configured. Type checking: `npx tsc --noEmit`. -后续会随着 Agent 生态的发展持续迭代。 +Release is triggered by pushing `v*` tags (see `.github/workflows/release.yml`). -## 设计哲学 +## Architecture -### 如非必要,勿增实体 +Electron 3-process model with a minimal, no-framework renderer: -这是 ColaMD 的第一原则。每增加一个 UI 元素、一个功能、一行代码,都要问:这是绝对必要的吗?默认答案是否。 +``` +Main Process (src/main/index.ts) + Node.js APIs: fs.watch, dialog, shell, Menu + Responsibilities: window lifecycle, file I/O, file watching, agent detection, + menus, PDF/HTML export, custom theme storage + ↕ IPC (ipcMain / ipcRenderer) +Preload (src/preload/index.ts) + contextBridge.exposeInMainWorld → window.electronAPI + Defines typed ElectronAPI interface (invoke + event listener patterns) + ↕ +Renderer (src/renderer/) + Plain TypeScript, no React/Vue — Milkdown IS the UI + main.ts → wires IPC events to editor actions + editor/editor.ts → Milkdown setup (commonmark, gfm, history, listener, clipboard) + editor/html-view.ts → custom inline HTML node view + themes/base.css → ALL CSS: reset, layout, 4 built-in themes (via CSS custom properties) + themes/theme-manager.ts → theme switching & localStorage persistence +``` -- 不要工具栏(用户会用快捷键和 Markdown 语法) -- 不要侧边栏 -- 不要状态栏 -- 界面只有:标题栏(拖拽用)+ 编辑器 -- 追求极致的简单,一个功能做到极致 +### Key Mechanism: File Hot Update (Core Feature) -### 核心功能优先级 +The main process uses `fs.watch()` on the open file. When external changes are detected: -1. **文件热更新**(核心卖点)— 外部 Agent 修改 .md 时自动刷新,实时看到 Agent 的工作 -2. **所见即所得** — 输入 Markdown 即刻渲染为富文本 -3. **主题系统** — CSS 主题,可导入自定义主题 -4. **导出** — PDF、HTML +1. Check `isInternalSave` flag — if true (our own save within 100ms), ignore to prevent feedback loops +2. Agent activity state machine: `idle` → `active` (rapid writes, gap < 2s) → `cooldown` (3s after last write) → `idle` (2s later). Visual feedback via CSS animation on `#agent-dot`. +3. Debounce 100ms → read file → send `file-changed` IPC to renderer → `setMarkdown()` replaces all content -### 不做的事情 +Each BrowserWindow has independent `WindowState` (filePath, watcher, agent state). Opening a file already open in another window focuses that window instead. -- 不做文件管理、文件树、工作区 -- 不做知识库管理 -- 不做云同步、协作编辑 -- 不做笔记组织和标签系统 -- 不加不必要的 UI 元素(工具栏、侧边栏等) +### IPC Pattern -## 技术栈 +- **Renderer → Main**: `ipcRenderer.invoke()` for request/response (openFile, saveFile, exportPDF, etc.) +- **Main → Renderer**: `webContents.send()` for push events (file-changed, set-theme, agent-activity, menu-* actions) +- All methods are typed via the `ElectronAPI` interface in `src/preload/index.ts` -- Electron(桌面跨平台) -- Milkdown(基于 ProseMirror 的 WYSIWYG Markdown 框架) -- TypeScript 严格模式 -- electron-vite(构建) -- electron-builder(打包) +### Theme System -## 项目结构 +- Built-in themes: `light`, `dark`, `elegant` (default), `newsprint` — switched by CSS class on `` +- Custom themes: stored as `.css` files in `~/.colamd/themes/`, injected via ` ${getHTML()}` - api.exportHTML(html) + api.exportHTML(sanitizeExportHTML(html)) }) + api.onNewFile(() => setMarkdown('')) api.onFileOpened((data) => setMarkdown(data.content)) - api.onFileChanged((content) => setMarkdown(content)) + + // file-changed: show diff highlight for agent changes + api.onFileChanged((content) => setMarkdown(content, true)) + api.onSetTheme((theme) => applyTheme(theme)) api.onSetCustomCSS((css) => { const theme = loadSavedTheme() @@ -106,4 +110,16 @@ img{max-width:100%} }) } +// HTML export sanitization: remove dangerous tags +const DANGEROUS_TAGS = /<(script|iframe|object|embed|form|input|button|textarea|select|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi +const DANGEROUS_OPEN = /<(script|iframe|object|embed|form|input|button|textarea|select|style)\b[^>]*\/?>\s*/gi +const DANGEROUS_ATTRS = /\s+on\w+\s*=\s*("[^"]*"|'[^']*'|\S+)/gi + +function sanitizeExportHTML(html: string): string { + return html + .replace(DANGEROUS_TAGS, '') + .replace(DANGEROUS_OPEN, '') + .replace(DANGEROUS_ATTRS, '') +} + init().catch((e) => console.error('ColaMD init failed:', e)) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index 75a9a1e..82f232d 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -315,3 +315,5 @@ body.theme-newsprint #editor .ProseMirror { background: var(--border-color); border-radius: 3px; } + + < /dev/null \ No newline at end of file From e64ddf1e7108273468d08f9be8628a9ac2522986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 00:40:32 -0700 Subject: [PATCH 2/7] Harden file open flow and renderer startup handling --- package.json | 1 + src/main/index.ts | 134 ++++++++++++++++++------------- src/preload/index.ts | 2 + src/renderer/editor/editor.ts | 2 +- src/renderer/editor/html-view.ts | 36 +-------- src/renderer/editor/sanitize.ts | 76 ++++++++++++++++++ src/renderer/main.ts | 107 +++++++++++++++++++----- src/renderer/themes/base.css | 26 +++++- 8 files changed, 271 insertions(+), 113 deletions(-) create mode 100644 src/renderer/editor/sanitize.ts diff --git a/package.json b/package.json index d06e691..258040d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "scripts": { "dev": "electron-vite dev", "build": "electron-vite build", + "typecheck": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.preload.json --noEmit && tsc -p tsconfig.renderer.json --noEmit", "preview": "electron-vite preview", "dist": "electron-vite build && electron-builder", "dist:mac": "electron-vite build && electron-builder --mac", diff --git a/src/main/index.ts b/src/main/index.ts index 4687efd..36d26f7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron' import { join, basename } from 'path' -import { readFile, writeFile, readdir, copyFile, mkdir, stat } from 'fs/promises' -import { watch, FSWatcher, existsSync, readdirSync } from 'fs' +import { readFile, writeFile, copyFile, mkdir, stat } from 'fs/promises' +import { watch, FSWatcher, readdirSync } from 'fs' // Custom themes directory // Agent detection constants @@ -21,14 +21,12 @@ async function readTextDocument(filePath: string): Promise { return readFile(filePath, 'utf-8') } -function ensureThemesDir(): void { - if (!existsSync(themesDir)) { - mkdir(themesDir, { recursive: true }).catch(() => {}) - } +async function ensureThemesDir(): Promise { + await mkdir(themesDir, { recursive: true }) } - return [] - } +interface AppErrorPayload { + message: string } // Per-window state @@ -58,6 +56,39 @@ function getWinFromEvent(event: Electron.IpcMainInvokeEvent): BrowserWindow | nu return BrowserWindow.fromWebContents(event.sender) } +function sendError(win: BrowserWindow, message: string): void { + if (!win.isDestroyed()) { + win.webContents.send('app-error', { message } satisfies AppErrorPayload) + } +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function formatReadError(error: unknown): string { + const message = getErrorMessage(error, 'Could not open file.') + if (message === 'Not a regular file') return 'Only regular text files can be opened.' + if (message === 'File too large for live sync') return 'This file is too large for live sync.' + if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return 'The file no longer exists.' + if ((error as NodeJS.ErrnoException | undefined)?.code === 'EACCES') return 'You do not have permission to open this file.' + return 'Could not open this file as UTF-8 text.' +} + +async function openDocumentInWindow(win: BrowserWindow, filePath: string): Promise<{ path: string; content: string } | null> { + try { + const content = await readTextDocument(filePath) + const state = getState(win) + state.filePath = filePath + watchFile(win, state) + updateTitle(win) + return { path: filePath, content } + } catch (error) { + sendError(win, formatReadError(error)) + return null + } +} + function createWindow(filePath?: string): BrowserWindow { const win = new BrowserWindow({ width: 960, @@ -70,7 +101,7 @@ function createWindow(filePath?: string): BrowserWindow { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, - sandbox: false + sandbox: true } }) @@ -84,7 +115,7 @@ function createWindow(filePath?: string): BrowserWindow { win.webContents.on('did-finish-load', () => { if (filePath) { - loadFileInWindow(win, filePath) + void loadFileInWindow(win, filePath) } }) @@ -185,12 +216,15 @@ function watchFile(win: BrowserWindow, state: WindowState): void { if (state.debounceTimer) clearTimeout(state.debounceTimer) state.debounceTimer = setTimeout(() => { - readFile(filePath, 'utf-8') + readTextDocument(filePath) .then((data) => { if (!win.isDestroyed()) win.webContents.send('file-changed', data) }) - .catch(() => {}) - }, 100) + .catch((error) => { + console.error('[watchFile] read error:', error) + sendError(win, formatReadError(error)) + }) + }, FILE_DEBOUNCE_MS) }) state.watcher.on('error', (error) => { @@ -200,16 +234,11 @@ function watchFile(win: BrowserWindow, state: WindowState): void { }) } -function loadFileInWindow(win: BrowserWindow, filePath: string): void { - readFile(filePath, 'utf-8') - .then((data) => { - const state = getState(win) - state.filePath = filePath - watchFile(win, state) - updateTitle(win) - win.webContents.send('file-opened', { path: filePath, content: data }) - }) - .catch(() => {}) +async function loadFileInWindow(win: BrowserWindow, filePath: string): Promise { + const result = await openDocumentInWindow(win, filePath) + if (result && !win.isDestroyed()) { + win.webContents.send('file-opened', result) + } } // Find window that already has this file open @@ -234,7 +263,7 @@ function openFile(filePath: string): void { // Find an untitled empty window to reuse const emptyWin = findEmptyWindow() if (emptyWin) { - loadFileInWindow(emptyWin, filePath) + void loadFileInWindow(emptyWin, filePath) emptyWin.focus() return } @@ -262,7 +291,8 @@ async function saveToPath(win: BrowserWindow, filePath: string, content: string) watchFile(win, state) updateTitle(win) return true - } catch { + } catch (error) { + sendError(win, getErrorMessage(error, 'Could not save file.')) return false } finally { setTimeout(() => { state.isInternalSave = false }, INTERNAL_SAVE_SUPPRESS_MS) @@ -295,15 +325,7 @@ ipcMain.handle('open-file', async (event) => { // If this window has no file, load here; otherwise open in new window const state = getState(win) if (!state.filePath) { - try { - const content = await readFile(filePath, 'utf-8') - state.filePath = filePath - watchFile(win, state) - updateTitle(win) - return { path: filePath, content } - } catch { - return null - } + return openDocumentInWindow(win, filePath) } else { openFile(filePath) return null @@ -317,15 +339,7 @@ ipcMain.handle('open-file-path', async (event, filePath: string) => { // If this window has no file, load here if (!state.filePath) { - try { - const content = await readFile(filePath, 'utf-8') - state.filePath = filePath - watchFile(win, state) - updateTitle(win) - return { path: filePath, content } - } catch { - return null - } + return openDocumentInWindow(win, filePath) } else { openFile(filePath) return null @@ -373,21 +387,25 @@ ipcMain.handle('export-pdf', async (event) => { }) if (result.canceled || !result.filePath) return false + let cssKey: string | null = null try { // Expand editor to full content height for printing - const cssKey = await win.webContents.insertCSS( + cssKey = await win.webContents.insertCSS( 'html, body { height: auto !important; overflow: visible !important; } #titlebar { display: none !important; } #editor { height: auto !important; overflow: visible !important; } #editor .ProseMirror { min-height: auto !important; }' ) const pdfData = await win.webContents.printToPDF({ - marginType: 0, printBackground: true, pageSize: 'A4' }) - await win.webContents.removeInsertedCSS(cssKey) await writeFile(result.filePath, pdfData) return true - } catch { + } catch (error) { + sendError(win, getErrorMessage(error, 'Could not export PDF.')) return false + } finally { + if (cssKey) { + try { await win.webContents.removeInsertedCSS(cssKey) } catch { /* ignore cleanup failure */ } + } } }) @@ -403,7 +421,8 @@ ipcMain.handle('export-html', async (event, htmlContent: string) => { try { await writeFile(result.filePath, htmlContent, 'utf-8') return true - } catch { + } catch (error) { + sendError(win, getErrorMessage(error, 'Could not export HTML.')) return false } }) @@ -418,6 +437,7 @@ ipcMain.handle('load-custom-theme', async (event) => { if (result.canceled || result.filePaths.length === 0) return null try { + await ensureThemesDir() const srcPath = result.filePaths[0] const fileName = basename(srcPath) const destPath = join(themesDir, fileName) @@ -425,7 +445,8 @@ ipcMain.handle('load-custom-theme', async (event) => { const css = await readFile(destPath, 'utf-8') buildMenu() // rebuild menu to include new theme return { name: fileName, css } - } catch { + } catch (error) { + sendError(win, getErrorMessage(error, 'Could not import theme.')) return null } }) @@ -435,12 +456,14 @@ function resolveThemePath(fileName: string): string | null { return join(themesDir, fileName) } -ipcMain.handle('load-theme-css', async (_event, fileName: string) => { +ipcMain.handle('load-theme-css', async (event, fileName: string) => { + const win = getWinFromEvent(event) try { const themePath = resolveThemePath(fileName) if (!themePath) return null return await readFile(themePath, 'utf-8') - } catch { + } catch (error) { + if (win) sendError(win, getErrorMessage(error, 'Could not load theme CSS.')) return null } }) @@ -467,11 +490,14 @@ function buildMenu(): void { customThemeItems.push({ label: file.replace(/\.css$/, ''), click: async () => { + const win = getFocusedWindow() try { const css = await readFile(join(themesDir, file), 'utf-8') sendToFocused('set-theme', `custom:${file}`) sendToFocused('set-custom-css', css) - } catch { /* ignore */ } + } catch (error) { + if (win) sendError(win, getErrorMessage(error, 'Could not load theme CSS.')) + } } }) } @@ -583,8 +609,8 @@ function buildMenu(): void { // App lifecycle -app.whenReady().then(() => { - ensureThemesDir() +app.whenReady().then(async () => { + await ensureThemesDir() buildMenu() // Check command line args for file paths diff --git a/src/preload/index.ts b/src/preload/index.ts index 53f58c8..f5ea6e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,7 @@ export interface ElectronAPI { loadThemeCSS: (fileName: string) => Promise getPathForFile: (file: File) => string openExternal: (url: string) => void + onAppError: (callback: (payload: { message: string }) => void) => Unsubscribe onFileChanged: (callback: (content: string) => void) => Unsubscribe onNewFile: (callback: () => void) => Unsubscribe onFileOpened: (callback: (data: { path: string; content: string }) => void) => Unsubscribe @@ -44,6 +45,7 @@ contextBridge.exposeInMainWorld('electronAPI', { loadThemeCSS: (fileName: string) => ipcRenderer.invoke('load-theme-css', fileName), getPathForFile: (file: File) => webUtils.getPathForFile(file), openExternal: (url: string) => ipcRenderer.send('open-external', url), + onAppError: (callback) => on('app-error', callback), onFileChanged: (callback) => on('file-changed', callback), onNewFile: (callback) => on('new-file', callback), onFileOpened: (callback) => on('file-opened', callback), diff --git a/src/renderer/editor/editor.ts b/src/renderer/editor/editor.ts index a6dc6ef..2959eb1 100644 --- a/src/renderer/editor/editor.ts +++ b/src/renderer/editor/editor.ts @@ -119,7 +119,7 @@ export async function createEditor( .config((ctx) => { ctx.set(rootCtx, root) ctx.set(defaultValueCtx, defaultContent) - ctx.set(remarkPluginsCtx, [{ plugin: remarkBreaks, options: undefined }]) + ctx.set(remarkPluginsCtx, [{ plugin: remarkBreaks, options: {} }]) if (onChange) { ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => { onChange(markdown) diff --git a/src/renderer/editor/html-view.ts b/src/renderer/editor/html-view.ts index 1fa9af5..4cc3dce 100644 --- a/src/renderer/editor/html-view.ts +++ b/src/renderer/editor/html-view.ts @@ -1,46 +1,14 @@ import { $view } from '@milkdown/kit/utils' import { htmlSchema } from '@milkdown/kit/preset/commonmark' import type { NodeViewConstructor } from '@milkdown/kit/prose/view' - -const ALLOWED_HTML_TAGS = new Set([ - 'kbd', 'mark', 'sub', 'sup', 'br', 'hr', 'abbr', 'del', 'ins', - 'span', 'div', 'details', 'summary', 'small', 'strong', 'em', - 'code', 'pre', 'b', 'i', 'u', 's', 'table', 'thead', 'tbody', - 'tr', 'th', 'td', 'blockquote', 'ul', 'ol', 'li', 'p', 'a', - 'img', 'figure', 'figcaption', 'ruby', 'rt', 'rp' -]) - -const DANGEROUS_ATTR_RE = /\bon\w+\s*=/i - -function sanitizeInlineHTML(raw: string): string { - // Allow safe inline HTML, strip dangerous content - const doc = new DOMParser().parseFromString(raw, 'text/html') - const walk = (node: Element): void => { - // Remove event handler attributes - for (const attr of Array.from(node.attributes)) { - if (DANGEROUS_ATTR_RE.test(attr.name)) { - node.removeAttribute(attr.name) - } - } - // Remove non-allowed tags but keep their text content - for (const child of Array.from(node.children)) { - if (!ALLOWED_HTML_TAGS.has(child.tagName.toLowerCase())) { - child.replaceWith(...Array.from(child.childNodes)) - } else { - walk(child) - } - } - } - walk(doc.body) - return doc.body.innerHTML -} +import { sanitizeHTMLFragment } from './sanitize' export const htmlView = $view(htmlSchema.node, (): NodeViewConstructor => { return (node) => { const dom = document.createElement('span') dom.classList.add('milkdown-html-inline') const rawHtml = typeof node.attrs.value === 'string' ? node.attrs.value : '' - dom.innerHTML = sanitizeInlineHTML(rawHtml) + dom.innerHTML = sanitizeHTMLFragment(rawHtml) return { dom, stopEvent: () => true diff --git a/src/renderer/editor/sanitize.ts b/src/renderer/editor/sanitize.ts new file mode 100644 index 0000000..6144aa0 --- /dev/null +++ b/src/renderer/editor/sanitize.ts @@ -0,0 +1,76 @@ +const ALLOWED_HTML_TAGS = new Set([ + 'kbd', 'mark', 'sub', 'sup', 'br', 'hr', 'abbr', 'del', 'ins', + 'span', 'div', 'details', 'summary', 'small', 'strong', 'em', + 'code', 'pre', 'b', 'i', 'u', 's', 'table', 'thead', 'tbody', + 'tr', 'th', 'td', 'blockquote', 'ul', 'ol', 'li', 'p', 'a', + 'img', 'figure', 'figcaption', 'ruby', 'rt', 'rp' +]) + +const GLOBAL_ALLOWED_ATTRS = new Set([ + 'title', 'class', 'role', 'lang', 'dir', 'aria-label', 'aria-hidden' +]) + +const TAG_ALLOWED_ATTRS: Record> = { + a: new Set(['href', 'target', 'rel']), + img: new Set(['src', 'alt', 'width', 'height']), + td: new Set(['colspan', 'rowspan', 'align']), + th: new Set(['colspan', 'rowspan', 'align']), + ol: new Set(['start']), + details: new Set(['open']) +} + +const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) + +function isSafeUrl(tagName: string, attrName: string, value: string): boolean { + const trimmed = value.trim() + if (!trimmed) return false + + if (trimmed.startsWith('#') || trimmed.startsWith('/')) return true + if (trimmed.startsWith('./') || trimmed.startsWith('../')) return true + if (tagName === 'img' && attrName === 'src' && /^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) return true + if (tagName === 'img' && attrName === 'src' && trimmed.startsWith('blob:')) return true + + try { + const parsed = new URL(trimmed, 'https://colamd.local') + return SAFE_PROTOCOLS.has(parsed.protocol) + } catch { + return false + } +} + +function shouldKeepAttribute(tagName: string, attrName: string, value: string): boolean { + if (attrName.startsWith('on') || attrName === 'style') return false + if (attrName.startsWith('aria-')) return true + if (GLOBAL_ALLOWED_ATTRS.has(attrName)) return true + if (attrName === 'href' || attrName === 'src') return isSafeUrl(tagName, attrName, value) + return TAG_ALLOWED_ATTRS[tagName]?.has(attrName) ?? false +} + +export function sanitizeHTMLFragment(raw: string): string { + const doc = new DOMParser().parseFromString(raw, 'text/html') + + const walk = (node: Element): void => { + for (const attr of Array.from(node.attributes)) { + const attrName = attr.name.toLowerCase() + if (!shouldKeepAttribute(node.tagName.toLowerCase(), attrName, attr.value)) { + node.removeAttribute(attr.name) + } + } + + if (node.tagName.toLowerCase() === 'a' && node.getAttribute('target') === '_blank') { + node.setAttribute('rel', 'noopener noreferrer') + } + + for (const child of Array.from(node.children)) { + const tagName = child.tagName.toLowerCase() + if (!ALLOWED_HTML_TAGS.has(tagName)) { + child.replaceWith(...Array.from(child.childNodes)) + } else { + walk(child) + } + } + } + + walk(doc.body) + return doc.body.innerHTML +} diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 44e4f64..20dc3f1 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,12 +1,57 @@ import { createEditor, getMarkdown, getHTML, setMarkdown } from './editor/editor' +import { sanitizeHTMLFragment } from './editor/sanitize' import { applyTheme, loadSavedTheme } from './themes/theme-manager' import './themes/base.css' +function ensureErrorToast(): HTMLElement { + let toast = document.getElementById('error-toast') + if (!toast) { + toast = document.createElement('div') + toast.id = 'error-toast' + document.body.appendChild(toast) + } + return toast +} + +let errorToastTimer: ReturnType | null = null + +function showError(message: string): void { + const toast = ensureErrorToast() + toast.textContent = message + toast.classList.add('visible') + if (errorToastTimer) clearTimeout(errorToastTimer) + errorToastTimer = setTimeout(() => { + toast.classList.remove('visible') + errorToastTimer = null + }, 4200) +} + async function init(): Promise { const api = window.electronAPI + let currentMarkdown = '' + let editorReady = false + let pendingOpenedContent: string | null = null + const pendingErrors: string[] = [] const savedTheme = loadSavedTheme() applyTheme(savedTheme) + api.onAppError(({ message }) => { + if (!editorReady) { + pendingErrors.push(message) + return + } + showError(message) + }) + + api.onFileOpened((data) => { + currentMarkdown = data.content + if (!editorReady) { + pendingOpenedContent = data.content + return + } + setMarkdown(data.content) + }) + // Restore custom theme CSS from disk if (savedTheme.startsWith('custom:')) { const fileName = savedTheme.slice(7) @@ -14,15 +59,29 @@ async function init(): Promise { if (css) applyTheme(savedTheme, css) } - await createEditor('editor') + await createEditor('editor', (markdown) => { + currentMarkdown = markdown + }) + editorReady = true + currentMarkdown = getMarkdown() + + if (pendingOpenedContent !== null) { + setMarkdown(pendingOpenedContent) + } + for (const message of pendingErrors) { + showError(message) + } api.onMenuOpen(async () => { const result = await api.openFile() - if (result) setMarkdown(result.content) + if (result) { + currentMarkdown = result.content + setMarkdown(result.content) + } }) - api.onMenuSave(() => api.saveFile(getMarkdown())) - api.onMenuSaveAs(() => api.saveFileAs(getMarkdown())) + api.onMenuSave(() => api.saveFile(currentMarkdown)) + api.onMenuSaveAs(() => api.saveFileAs(currentMarkdown)) api.onMenuExportPDF(() => api.exportPDF()) api.onMenuExportHTML(() => { @@ -71,15 +130,28 @@ hr{border:none;border-top:2px solid ${borderColor};margin:2em 0} img{max-width:100%} ::selection{background:${selectionBg}} -${getHTML()}` - api.exportHTML(sanitizeExportHTML(html)) +${sanitizeHTMLFragment(getHTML())}` + api.exportHTML(html) }) - api.onNewFile(() => setMarkdown('')) - api.onFileOpened((data) => setMarkdown(data.content)) + api.onNewFile(() => { + currentMarkdown = '' + pendingOpenedContent = null + setMarkdown('') + }) // file-changed: show diff highlight for agent changes - api.onFileChanged((content) => setMarkdown(content, true)) + api.onFileChanged((content) => { + if (content === currentMarkdown) return + + const editorEl = document.getElementById('editor') + const scrollTop = editorEl?.scrollTop ?? 0 + currentMarkdown = content + setMarkdown(content, true) + requestAnimationFrame(() => { + if (editorEl) editorEl.scrollTop = scrollTop + }) + }) api.onSetTheme((theme) => applyTheme(theme)) api.onSetCustomCSS((css) => { @@ -106,20 +178,11 @@ img{max-width:100%} const filePath = api.getPathForFile(file) if (!filePath) return const result = await api.openFilePath(filePath) - if (result) setMarkdown(result.content) + if (result) { + currentMarkdown = result.content + setMarkdown(result.content) + } }) } -// HTML export sanitization: remove dangerous tags -const DANGEROUS_TAGS = /<(script|iframe|object|embed|form|input|button|textarea|select|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi -const DANGEROUS_OPEN = /<(script|iframe|object|embed|form|input|button|textarea|select|style)\b[^>]*\/?>\s*/gi -const DANGEROUS_ATTRS = /\s+on\w+\s*=\s*("[^"]*"|'[^']*'|\S+)/gi - -function sanitizeExportHTML(html: string): string { - return html - .replace(DANGEROUS_TAGS, '') - .replace(DANGEROUS_OPEN, '') - .replace(DANGEROUS_ATTRS, '') -} - init().catch((e) => console.error('ColaMD init failed:', e)) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index 82f232d..c33058b 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -32,6 +32,30 @@ html, body { transition: background 0.3s ease, opacity 0.3s ease; } +#error-toast { + position: fixed; + right: 16px; + bottom: 16px; + max-width: min(420px, calc(100vw - 32px)); + padding: 12px 14px; + border-radius: 10px; + background: rgba(120, 25, 25, 0.94); + color: #fff; + font-size: 13px; + line-height: 1.45; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity 0.18s ease, transform 0.18s ease; + z-index: 20; +} + +#error-toast.visible { + opacity: 1; + transform: translateY(0); +} + #agent-dot.active { background: #e8913a; opacity: 1; @@ -315,5 +339,3 @@ body.theme-newsprint #editor .ProseMirror { background: var(--border-color); border-radius: 3px; } - - < /dev/null \ No newline at end of file From 2afba40fb4f39ded34c8a93d2251a64108b06d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 01:03:50 -0700 Subject: [PATCH 3/7] Add lightweight agent activity timeline UI --- src/renderer/index.html | 15 +++- src/renderer/main.ts | 128 ++++++++++++++++++++++++++++++++++- src/renderer/themes/base.css | 83 ++++++++++++++++++++++- 3 files changed, 220 insertions(+), 6 deletions(-) diff --git a/src/renderer/index.html b/src/renderer/index.html index 66576d7..dbf521a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,20 @@ ColaMD -
+
+
+
+ Waiting for agent activity + +
+
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 20dc3f1..d595fb1 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,4 +1,3 @@ -import { createEditor, getMarkdown, getHTML, setMarkdown } from './editor/editor' import { sanitizeHTMLFragment } from './editor/sanitize' import { applyTheme, loadSavedTheme } from './themes/theme-manager' import './themes/base.css' @@ -26,15 +25,118 @@ function showError(message: string): void { }, 4200) } +interface AgentChangeEntry { + id: number + summary: string + timestamp: number +} + +const MAX_AGENT_CHANGES = 5 + +function formatRelativeTime(timestamp: number): string { + const diffMs = Date.now() - timestamp + if (diffMs < 10_000) return 'just now' + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHour = Math.floor(diffMin / 60) + if (diffHour < 24) return `${diffHour}h ago` + const diffDay = Math.floor(diffHour / 24) + return `${diffDay}d ago` +} + +function summarizeAgentChange(previous: string, next: string): string { + const previousLines = previous.split('\n').map((line) => line.trim()).filter(Boolean) + const nextLines = next.split('\n').map((line) => line.trim()).filter(Boolean) + const added = nextLines.filter((line) => !previousLines.includes(line)) + const removed = previousLines.filter((line) => !nextLines.includes(line)) + + if (added.length > 0) { + return `Updated: ${added[0].slice(0, 140)}` + } + if (removed.length > 0) { + return `Removed: ${removed[0].slice(0, 140)}` + } + return 'Agent updated the document structure.' +} + async function init(): Promise { const api = window.electronAPI let currentMarkdown = '' let editorReady = false let pendingOpenedContent: string | null = null const pendingErrors: string[] = [] + let getMarkdown = () => '' + let getHTML = () => '' + let setMarkdown = (_content: string, _showDiff?: boolean) => {} + let agentState: 'idle' | 'active' | 'cooldown' = 'idle' + let lastAgentUpdateAt: number | null = null + let agentUpdateCount = 0 + let agentChangeId = 0 + let agentChanges: AgentChangeEntry[] = [] const savedTheme = loadSavedTheme() applyTheme(savedTheme) + const agentStatus = document.getElementById('agent-status') + const agentPanel = document.getElementById('agent-panel') + const agentPanelToggle = document.getElementById('agent-panel-toggle') as HTMLButtonElement | null + const agentSummary = document.getElementById('agent-summary') + const agentChangeList = document.getElementById('agent-change-list') + + const renderAgentUI = (): void => { + if (agentStatus) { + if (agentState === 'active') { + agentStatus.textContent = 'Agent editing...' + } else if (lastAgentUpdateAt) { + agentStatus.textContent = `Updated ${formatRelativeTime(lastAgentUpdateAt)} • ${agentUpdateCount} sync${agentUpdateCount === 1 ? '' : 's'}` + } else { + agentStatus.textContent = 'Waiting for agent activity' + } + } + + if (agentSummary) { + agentSummary.textContent = lastAgentUpdateAt + ? `Last update ${formatRelativeTime(lastAgentUpdateAt)}` + : 'No external updates yet' + } + + if (agentChangeList) { + agentChangeList.innerHTML = '' + for (const change of agentChanges) { + const item = document.createElement('li') + const time = document.createElement('span') + time.className = 'agent-change-time' + time.textContent = formatRelativeTime(change.timestamp) + const summary = document.createElement('div') + summary.className = 'agent-change-summary' + summary.textContent = change.summary + item.append(time, summary) + agentChangeList.appendChild(item) + } + } + } + + const resetAgentTimeline = (): void => { + agentState = 'idle' + lastAgentUpdateAt = null + agentUpdateCount = 0 + agentChangeId = 0 + agentChanges = [] + renderAgentUI() + } + + agentPanelToggle?.addEventListener('click', () => { + const open = agentPanel?.hidden ?? true + if (agentPanel) agentPanel.hidden = !open + if (agentPanelToggle) agentPanelToggle.setAttribute('aria-expanded', String(open)) + }) + + setInterval(() => { + if (lastAgentUpdateAt && agentState !== 'active') renderAgentUI() + }, 30_000) + renderAgentUI() + api.onAppError(({ message }) => { if (!editorReady) { pendingErrors.push(message) @@ -49,6 +151,7 @@ async function init(): Promise { pendingOpenedContent = data.content return } + resetAgentTimeline() setMarkdown(data.content) }) @@ -59,9 +162,13 @@ async function init(): Promise { if (css) applyTheme(savedTheme, css) } - await createEditor('editor', (markdown) => { + const editorModule = await import('./editor/editor') + await editorModule.createEditor('editor', (markdown) => { currentMarkdown = markdown }) + getMarkdown = editorModule.getMarkdown + getHTML = editorModule.getHTML + setMarkdown = editorModule.setMarkdown editorReady = true currentMarkdown = getMarkdown() @@ -76,6 +183,7 @@ async function init(): Promise { const result = await api.openFile() if (result) { currentMarkdown = result.content + resetAgentTimeline() setMarkdown(result.content) } }) @@ -137,6 +245,7 @@ img{max-width:100%} api.onNewFile(() => { currentMarkdown = '' pendingOpenedContent = null + resetAgentTimeline() setMarkdown('') }) @@ -144,9 +253,21 @@ img{max-width:100%} api.onFileChanged((content) => { if (content === currentMarkdown) return + const previousMarkdown = currentMarkdown const editorEl = document.getElementById('editor') const scrollTop = editorEl?.scrollTop ?? 0 currentMarkdown = content + lastAgentUpdateAt = Date.now() + agentUpdateCount += 1 + agentChanges = [ + { + id: ++agentChangeId, + summary: summarizeAgentChange(previousMarkdown, content), + timestamp: lastAgentUpdateAt + }, + ...agentChanges + ].slice(0, MAX_AGENT_CHANGES) + renderAgentUI() setMarkdown(content, true) requestAnimationFrame(() => { if (editorEl) editorEl.scrollTop = scrollTop @@ -166,6 +287,8 @@ img{max-width:100%} const agentDot = document.getElementById('agent-dot') api.onAgentActivity((state) => { + agentState = state as 'idle' | 'active' | 'cooldown' + renderAgentUI() if (agentDot) agentDot.className = state === 'idle' ? '' : state }) @@ -180,6 +303,7 @@ img{max-width:100%} const result = await api.openFilePath(filePath) if (result) { currentMarkdown = result.content + resetAgentTimeline() setMarkdown(result.content) } }) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index c33058b..59e8d16 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -19,19 +19,96 @@ html, body { position: relative; } -#agent-dot { +#agent-meta { position: absolute; - top: 22px; + top: 14px; right: 16px; + display: flex; + align-items: center; + gap: 10px; + -webkit-app-region: no-drag; +} + +#agent-status { + max-width: min(38vw, 320px); + color: var(--text-muted); + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#agent-panel-toggle { + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-color) 90%, var(--text-color) 10%); + color: var(--text-color); + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + cursor: pointer; +} + +#agent-panel-toggle:hover { + background: color-mix(in srgb, var(--bg-color) 82%, var(--text-color) 18%); +} + +#agent-dot { + position: static; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); opacity: 0.3; - -webkit-app-region: no-drag; transition: background 0.3s ease, opacity 0.3s ease; } +#agent-panel { + margin: 0 40px 16px; + padding: 14px 16px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-color) 96%, var(--text-color) 4%); +} + +#agent-panel[hidden] { + display: none; +} + +#agent-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +#agent-summary { + color: var(--text-muted); + font-size: 12px; +} + +#agent-change-list { + padding-left: 18px; +} + +#agent-change-list li + li { + margin-top: 10px; +} + +.agent-change-time { + display: block; + margin-bottom: 4px; + color: var(--text-muted); + font-size: 12px; +} + +.agent-change-summary { + color: var(--text-color); + font-size: 14px; + line-height: 1.5; +} + #error-toast { position: fixed; right: 16px; From 18ee02ec72601783518e3dc3ddfacc95b27f4a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 01:23:09 -0700 Subject: [PATCH 4/7] Make recent agent changes jump to content --- src/renderer/main.ts | 169 +++++++++++++++++++++++++++++++---- src/renderer/themes/base.css | 27 ++++++ 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/src/renderer/main.ts b/src/renderer/main.ts index d595fb1..c9276fa 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -29,6 +29,7 @@ interface AgentChangeEntry { id: number summary: string timestamp: number + targetText: string | null } const MAX_AGENT_CHANGES = 5 @@ -46,19 +47,117 @@ function formatRelativeTime(timestamp: number): string { return `${diffDay}d ago` } -function summarizeAgentChange(previous: string, next: string): string { - const previousLines = previous.split('\n').map((line) => line.trim()).filter(Boolean) - const nextLines = next.split('\n').map((line) => line.trim()).filter(Boolean) - const added = nextLines.filter((line) => !previousLines.includes(line)) - const removed = previousLines.filter((line) => !nextLines.includes(line)) +function isHeadingLine(line: string): boolean { + return /^#{1,6}\s+/.test(line.trim()) +} + +function normalizeLine(line: string): string { + return line.trim() +} + +function countLineOccurrences(lines: string[]): Map { + const counts = new Map() + for (const line of lines) { + counts.set(line, (counts.get(line) ?? 0) + 1) + } + return counts +} + +function collectLineDiff(previousLines: string[], nextLines: string[]): { added: string[]; removed: string[] } { + const previousCounts = countLineOccurrences(previousLines) + const nextCounts = countLineOccurrences(nextLines) + const added: string[] = [] + const removed: string[] = [] + + for (const line of nextLines) { + const count = previousCounts.get(line) ?? 0 + if (count > 0) { + previousCounts.set(line, count - 1) + } else { + added.push(line) + } + } + + for (const line of previousLines) { + const count = nextCounts.get(line) ?? 0 + if (count > 0) { + nextCounts.set(line, count - 1) + } else { + removed.push(line) + } + } + + return { added, removed } +} + +function findFirstChangedLineIndex(previousLines: string[], nextLines: string[]): number { + const max = Math.max(previousLines.length, nextLines.length) + for (let i = 0; i < max; i++) { + if ((previousLines[i] ?? '') !== (nextLines[i] ?? '')) return i + } + return -1 +} + +function findHeadingContext(lines: string[], changedIndex: number): string | null { + const safeIndex = Math.min(Math.max(changedIndex, 0), lines.length - 1) + for (let i = safeIndex; i >= 0; i--) { + const line = lines[i]?.trim() ?? '' + if (isHeadingLine(line)) return line.slice(0, 80) + } + return null +} + +function buildChangeLabel(addedCount: number, removedCount: number): string { + if (addedCount > 0 && removedCount > 0) return 'Updated' + if (addedCount > 0) return 'Added' + if (removedCount > 0) return 'Removed' + return 'Reworked' +} + +function formatLineDelta(addedCount: number, removedCount: number): string { + if (addedCount === 0 && removedCount === 0) return '' + if (addedCount > 0 && removedCount > 0) return ` (+${addedCount}/-${removedCount} lines)` + if (addedCount > 0) return ` (+${addedCount} lines)` + return ` (-${removedCount} lines)` +} - if (added.length > 0) { - return `Updated: ${added[0].slice(0, 140)}` +function analyzeAgentChange(previous: string, next: string): { summary: string; targetText: string | null } { + const previousLines = previous.split('\n').map(normalizeLine).filter(Boolean) + const nextLines = next.split('\n').map(normalizeLine).filter(Boolean) + const { added, removed } = collectLineDiff(previousLines, nextLines) + const changedIndex = findFirstChangedLineIndex(previousLines, nextLines) + const headingContext = findHeadingContext(nextLines, changedIndex >= 0 ? changedIndex : nextLines.length - 1) + ?? findHeadingContext(previousLines, changedIndex >= 0 ? changedIndex : previousLines.length - 1) + const label = buildChangeLabel(added.length, removed.length) + const lineDelta = formatLineDelta(added.length, removed.length) + + if (headingContext) { + return { + summary: `${label} ${headingContext}${lineDelta}`, + targetText: headingContext.replace(/^#{1,6}\s+/, '') + } } - if (removed.length > 0) { - return `Removed: ${removed[0].slice(0, 140)}` + + const firstAdded = added.find((line) => !isHeadingLine(line)) + if (firstAdded) { + return { + summary: `${label} paragraph: ${firstAdded.slice(0, 110)}${lineDelta}`, + targetText: firstAdded + } + } + + const firstRemoved = removed.find((line) => !isHeadingLine(line)) + if (firstRemoved) { + return { + summary: `${label} paragraph: ${firstRemoved.slice(0, 110)}${lineDelta}`, + targetText: firstRemoved + } + } + + return { + summary: `Agent updated the document structure${lineDelta}`.trim(), + targetText: null } - return 'Agent updated the document structure.' } async function init(): Promise { @@ -84,6 +183,38 @@ async function init(): Promise { const agentSummary = document.getElementById('agent-summary') const agentChangeList = document.getElementById('agent-change-list') + const jumpToAgentChange = (change: AgentChangeEntry): void => { + const editorEl = document.getElementById('editor') + const proseMirror = document.querySelector('#editor .ProseMirror') + if (!editorEl || !proseMirror) return + + const children = Array.from(proseMirror.children) as HTMLElement[] + const normalizedTarget = change.targetText?.trim().toLowerCase() + let match: HTMLElement | null = null + + if (normalizedTarget) { + match = children.find((child) => { + const text = child.textContent?.trim().toLowerCase() ?? '' + return text === normalizedTarget || text.startsWith(normalizedTarget) || text.includes(normalizedTarget) + }) ?? null + } + + if (!match) { + match = children[0] ?? null + } + if (!match) return + + match.classList.remove('agent-change-target') + editorEl.scrollTo({ + top: Math.max(match.offsetTop - 24, 0), + behavior: 'smooth' + }) + requestAnimationFrame(() => { + match?.classList.add('agent-change-target') + setTimeout(() => match?.classList.remove('agent-change-target'), 1800) + }) + } + const renderAgentUI = (): void => { if (agentStatus) { if (agentState === 'active') { @@ -108,9 +239,15 @@ async function init(): Promise { const time = document.createElement('span') time.className = 'agent-change-time' time.textContent = formatRelativeTime(change.timestamp) - const summary = document.createElement('div') - summary.className = 'agent-change-summary' - summary.textContent = change.summary + const summary = document.createElement('button') + summary.type = 'button' + summary.className = 'agent-change-link' + summary.addEventListener('click', () => jumpToAgentChange(change)) + summary.title = change.targetText ? `Jump to ${change.targetText}` : 'Jump to document' + const summaryText = document.createElement('span') + summaryText.className = 'agent-change-summary' + summaryText.textContent = change.summary + summary.appendChild(summaryText) item.append(time, summary) agentChangeList.appendChild(item) } @@ -259,11 +396,13 @@ img{max-width:100%} currentMarkdown = content lastAgentUpdateAt = Date.now() agentUpdateCount += 1 + const changeInfo = analyzeAgentChange(previousMarkdown, content) agentChanges = [ { id: ++agentChangeId, - summary: summarizeAgentChange(previousMarkdown, content), - timestamp: lastAgentUpdateAt + summary: changeInfo.summary, + timestamp: lastAgentUpdateAt, + targetText: changeInfo.targetText }, ...agentChanges ].slice(0, MAX_AGENT_CHANGES) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index 59e8d16..41dfd94 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -103,12 +103,30 @@ html, body { font-size: 12px; } +.agent-change-link { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.agent-change-link:hover .agent-change-summary { + color: var(--link-color); +} + .agent-change-summary { color: var(--text-color); font-size: 14px; line-height: 1.5; } +#editor .ProseMirror .agent-change-target { + animation: agent-jump-highlight 1.8s ease; +} + #error-toast { position: fixed; right: 16px; @@ -150,6 +168,15 @@ html, body { 50% { opacity: 1; transform: scale(1.05); } } +@keyframes agent-jump-highlight { + 0% { + background: color-mix(in srgb, var(--link-color) 22%, transparent); + } + 100% { + background: transparent; + } +} + #editor { height: calc(100vh - 52px); overflow-y: auto; From 988f7d692dfe636b7550f689e099b347ea822e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 17:53:20 -0700 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PR=20review=20?= =?UTF-8?q?=E6=8C=87=E5=87=BA=E7=9A=84=E4=B8=89=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. sanitize.ts: 修复 replaceWith 后被提升子元素属性未清理的问题。 之前 disallowed 标签被替换时,其子元素会被提升但属性未被 sanitize。 现在先收集被提升的 Element 节点,替换后再对它们递归 walk。 2. base.css: 添加 agent-diff-added 和 agent-diff-fadeout CSS 规则, 使 diff 高亮可见,并使 animationend 事件能正常触发。 3. main/index.ts: 修复 watchFile 中 fs.watch() 同步异常导致进程崩溃。 用 try-catch 包裹 watch() 调用,异常时停止 watcher 并重试。 Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 75 ++++++++++++++++++--------------- src/renderer/editor/sanitize.ts | 11 ++++- src/renderer/themes/base.css | 16 +++++++ 3 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 36d26f7..d66ba69 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -192,40 +192,47 @@ function watchFile(win: BrowserWindow, state: WindowState): void { if (!state.filePath) return stopWatching(state) const filePath = state.filePath - state.watcher = watch(filePath, { persistent: false }, (eventType) => { - if (state.isInternalSave) return - - // Handle rename (atomic saves from editors like vim) - if (eventType === 'rename') { - stopWatching(state) - setTimeout(() => watchFile(win, state), 50) - return - } - - if (eventType !== 'change') return - - // Agent activity detection - const now = Date.now() - const gap = now - state.lastExternalChange - state.lastExternalChange = now - if (gap > 0 && gap < AGENT_ACTIVE_GAP_MS) { - transitionAgentState(win, state, 'active') - } else if (state.agentState === 'active') { - transitionAgentState(win, state, 'active') - } - - if (state.debounceTimer) clearTimeout(state.debounceTimer) - state.debounceTimer = setTimeout(() => { - readTextDocument(filePath) - .then((data) => { - if (!win.isDestroyed()) win.webContents.send('file-changed', data) - }) - .catch((error) => { - console.error('[watchFile] read error:', error) - sendError(win, formatReadError(error)) - }) - }, FILE_DEBOUNCE_MS) - }) + try { + state.watcher = watch(filePath, { persistent: false }, (eventType) => { + if (state.isInternalSave) return + + // Handle rename (atomic saves from editors like vim) + if (eventType === 'rename') { + stopWatching(state) + setTimeout(() => watchFile(win, state), 50) + return + } + + if (eventType !== 'change') return + + // Agent activity detection + const now = Date.now() + const gap = now - state.lastExternalChange + state.lastExternalChange = now + if (gap > 0 && gap < AGENT_ACTIVE_GAP_MS) { + transitionAgentState(win, state, 'active') + } else if (state.agentState === 'active') { + transitionAgentState(win, state, 'active') + } + + if (state.debounceTimer) clearTimeout(state.debounceTimer) + state.debounceTimer = setTimeout(() => { + readTextDocument(filePath) + .then((data) => { + if (!win.isDestroyed()) win.webContents.send('file-changed', data) + }) + .catch((error) => { + console.error('[watchFile] read error:', error) + sendError(win, formatReadError(error)) + }) + }, FILE_DEBOUNCE_MS) + }) + } catch (error) { + console.error('[watchFile] watch error:', error) + stopWatching(state) + setTimeout(() => watchFile(win, state), 500) + return + } state.watcher.on('error', (error) => { console.error('[watchFile] watcher error:', error) diff --git a/src/renderer/editor/sanitize.ts b/src/renderer/editor/sanitize.ts index 6144aa0..fd8c311 100644 --- a/src/renderer/editor/sanitize.ts +++ b/src/renderer/editor/sanitize.ts @@ -61,14 +61,23 @@ export function sanitizeHTMLFragment(raw: string): string { node.setAttribute('rel', 'noopener noreferrer') } + // Collect promoted children before processing so we can recurse into them + const promoted: Element[] = [] for (const child of Array.from(node.children)) { const tagName = child.tagName.toLowerCase() if (!ALLOWED_HTML_TAGS.has(tagName)) { - child.replaceWith(...Array.from(child.childNodes)) + // collect childNodes BEFORE replaceWith mutates the DOM + const childNodes = Array.from(child.childNodes) + promoted.push(...Array.from(childNodes).filter((n): n is Element => n instanceof Element)) + child.replaceWith(...childNodes) } else { walk(child) } } + // Walk promoted elements so their attributes are also sanitized + for (const el of promoted) { + walk(el) + } } walk(doc.body) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index 41dfd94..b71d24d 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -163,6 +163,22 @@ html, body { animation: none; } +/* Agent diff highlight */ +#editor .ProseMirror .agent-diff-added { + background: rgba(63, 185, 80, 0.15); + border-radius: 3px; + transition: background 0.3s ease; +} + +#editor .ProseMirror .agent-diff-fadeout { + animation: agent-diff-fadeout 0.4s ease forwards; +} + +@keyframes agent-diff-fadeout { + 0% { background: rgba(63, 185, 80, 0.25); } + 100% { background: transparent; } +} + @keyframes agent-breathe { 0%, 100% { opacity: 0.45; transform: scale(0.95); } 50% { opacity: 1; transform: scale(1.05); } From b3649e420f62970904cd55e90de82e6f76ac4ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 19:26:38 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20rename=20handle?= =?UTF-8?q?r=20=E7=9A=84=E4=B8=A4=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. rename 后重新读取文件内容:atomic save 会替换文件,之前只重启 watcher 但不读取内容,导致编辑器显示旧内容。现在立即读取并通知 渲染器。 2. rename 时保留 agent 状态:之前调用 stopWatching() 会重置 agentState 和 lastExternalChange,导致 agent dot 闪烁。改为只关闭 watcher, 不重置 agent 状态。 Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d66ba69..f66d0a1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -198,8 +198,26 @@ function watchFile(win: BrowserWindow, state: WindowState): void { // Handle rename (atomic saves from editors like vim) if (eventType === 'rename') { - stopWatching(state) - setTimeout(() => watchFile(win, state), 50) + // Close watcher without resetting agent state + if (state.watcher) { + state.watcher.close() + state.watcher = null + } + if (state.debounceTimer) { + clearTimeout(state.debounceTimer) + state.debounceTimer = null + } + // Re-read immediately since atomic save replaced the file + setTimeout(() => { + watchFile(win, state) + readTextDocument(filePath) + .then((data) => { + if (!win.isDestroyed()) win.webContents.send('file-changed', data) + }) + .catch((error) => { + console.error('[watchFile] rename read error:', error) + }) + }, 50) return } From 6d5dedaadcf4345dd6b4fc22d6d5e2ec96266b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E5=91=A8=E6=B4=8B?= Date: Wed, 1 Apr 2026 20:12:08 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20sanitize=20?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=20disallowed=20=E6=A0=87=E7=AD=BE=E5=92=8C?= =?UTF-8?q?=20jump=20=E5=AF=BC=E8=88=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. sanitize.ts: 递归检查被提升元素的标签本身是否 allowed。 之前内层 disallowed 标签被提升后不再检查,导致