From 05456fac2be3e810266609ecdff854a4843439f5 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 22:14:12 +0800 Subject: [PATCH] Show changes to the open file as they happen, marked A change on disk, such as an agent writing through MCP, replaces the diagram as one undoable change with what changed glowing; unsaved edits here ask which version to keep. --- README.md | 5 + .../tasks/fl-87 - See-the-agent-edits-live.md | 39 +++++- e2e/live.spec.js | 102 +++++++++++++++ src/components/canvas/FlowEdge.vue | 7 +- src/components/canvas/FlowNodeCard.vue | 6 + .../canvas/__tests__/FlowNodeCard.spec.js | 6 +- src/components/shell/FileConflictDialog.vue | 38 ++++++ src/composables/useDiagramFile.js | 28 ++++- src/composables/useLaunch.js | 2 +- src/composables/useWatchFile.js | 119 ++++++++++++++++++ src/stores/canvas.js | 28 +++++ src/stores/file.js | 28 ++++- src/style.css | 35 ++++++ src/views/FlowView.vue | 10 ++ 14 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 e2e/live.spec.js create mode 100644 src/components/shell/FileConflictDialog.vue create mode 100644 src/composables/useWatchFile.js diff --git a/README.md b/README.md index 9f36f31..e5a7c3b 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,11 @@ For Claude Desktop, or any client with a JSON config: Then ask: _"Read docs/architecture.flow and scaffold the services it shows"_, or _"Add the cache you just built to the architecture diagram"_. +Keep the same file open in isketch (_Open file_, in Chrome or Edge) and watch the agent draw: when +the file changes on disk, the canvas shows the new version within a second, with what was added or +changed glowing for a few seconds, and one Undo to go back. If you have edits of your own that are +not saved, isketch asks which to keep instead. + ## Hosted links for agents `server/` is a small NestJS and PostgreSQL service that stores a diagram behind an unguessable link diff --git a/backlog/tasks/fl-87 - See-the-agent-edits-live.md b/backlog/tasks/fl-87 - See-the-agent-edits-live.md index af0846e..ef6e247 100644 --- a/backlog/tasks/fl-87 - See-the-agent-edits-live.md +++ b/backlog/tasks/fl-87 - See-the-agent-edits-live.md @@ -1,9 +1,11 @@ --- id: FL-87 title: See the agent edits live -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 14:14' labels: - frontend - mcp @@ -25,8 +27,35 @@ When the open .flow file changes on disk (an agent writing through MCP or an edi -- [ ] #1 An open file that changes on disk reloads without losing unsaved-change protection -- [ ] #2 Changed shapes and connections are highlighted with the existing diff for a few seconds -- [ ] #3 A conflict with unsaved local edits asks which to keep +- [x] #1 An open file that changes on disk reloads without losing unsaved-change protection +- [x] #2 Changed shapes and connections are highlighted with the existing diff for a few seconds +- [x] #3 A conflict with unsaved local edits asks which to keep + +## Implementation Plan + + + +1. File store remembers what the file held (canonical .flow) and its lastModified, on open, launch and save. +2. useWatchFile polls the open handle every second (a browser cannot be notified); a newer lastModified whose parsed diagram differs from what was saved is a change. Nothing unsaved here: it replaces the diagram as one undoable change and flashes the diff. Unsaved edits: a dialog asks which to keep. A file that does not parse is refused with its line. +3. Canvas marks added, changed and moved shapes and added or changed connections for four seconds, in the diff colours. +4. e2e with a stand-in file handle whose contents the test changes. + + + +## Implementation Notes + + + +Own saves are recognised by comparing the canonical .flow of the file with what was last saved, not just the timestamp, so a save never reads as an outside change. Keep mine records the files version as the saved one, so the next outside change asks again and the next Save overwrites. The watcher pauses while the tab is hidden and while the dialog is open. + +Verified: e2e/live.spec.js (4: a change comes in with added and changed marks that fade, and undo; its own save is not a change; unsaved edits ask, Keep mine keeps them and the next change asks again, Use the files version replaces; a broken file is refused with its line). FlowNodeCard unit tests now mount with Pinia. e2e 144, vitest 300, lint and typecheck. + + +## Final Summary + + + +The open .flow file is watched: a change made on disk by an agent or an editor shows up within a second as one undoable change with what changed glowing for a few seconds, and a conflict with unsaved edits asks which to keep. Verified with e2e/live.spec.js. + diff --git a/e2e/live.spec.js b/e2e/live.spec.js new file mode 100644 index 0000000..f33aa2a --- /dev/null +++ b/e2e/live.spec.js @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test' + +import { fromMenu, history } from './helpers.js' + +const shapes = (page) => page.locator('.vue-flow__node') +const node = (page, id) => page.locator(`.vue-flow__node[data-id="${id}"]`) + +const ON_DISK = 'title: Checkout\npay = process "Pay"\nship = process "Ship"\npay -> ship\n' + +/** An agent, or an editor, writing the open file. */ +const writeDisk = (page, text) => + page.evaluate((next) => { + window.__disk = { text: next, modified: window.__disk.modified + 1000 } + }, text) + +test.beforeEach(async ({ page }) => { + // The opened file is a stand-in whose contents the test changes, as a writer on disk would. + await page.addInitScript((text) => { + window.__disk = { text, modified: 1_000_000 } + const handle = { + name: 'checkout.flow', + getFile: async () => + new File([window.__disk.text], 'checkout.flow', { lastModified: window.__disk.modified }), + createWritable: async () => { + let written = '' + return { + write: async (chunk) => (written += chunk), + close: async () => + (window.__disk = { text: written, modified: window.__disk.modified + 1 }), + } + }, + } + window.showOpenFilePicker = async () => [handle] + }, ON_DISK) + + await page.goto('/new') + await fromMenu(page, 'Open file') + await expect(shapes(page)).toHaveCount(2) +}) + +test('a change on disk comes in, marked, and undo takes it back', async ({ page }) => { + await writeDisk( + page, + `${ON_DISK.replace('"Pay"', '"Take payment"')}mail = process "Mail"\nship -> mail : notify\n`, + ) + + await expect(shapes(page)).toHaveCount(3) + await expect( + page.getByText('checkout.flow changed on disk. This is the new version.'), + ).toBeVisible() + await expect(node(page, 'mail').locator('[data-flash]')).toHaveAttribute('data-flash', 'added') + await expect(node(page, 'pay').locator('[data-flash]')).toHaveAttribute('data-flash', 'changed') + await expect(node(page, 'pay')).toContainText('Take payment') + // The mark fades after a few seconds. + await expect(page.locator('[data-flash]')).toHaveCount(0, { timeout: 8000 }) + + await history(page).getByRole('button', { name: 'Undo' }).click() + await expect(shapes(page)).toHaveCount(2) +}) + +test('its own save is not taken for a change on disk', async ({ page }) => { + await node(page, 'pay').click() + await page.getByLabel('Title').fill('Pay now') + await page.getByRole('button', { name: 'Save changes' }).click() + await page.keyboard.press('Control+s') + await expect(page.getByText('Saved to checkout.flow')).toBeVisible() + + await page.waitForTimeout(2500) + await expect(page.getByText(/changed on disk/)).toHaveCount(0) + await expect(page.getByRole('dialog', { name: 'The file changed on disk' })).toHaveCount(0) +}) + +test('with unsaved edits here, it asks which to keep', async ({ page }) => { + await node(page, 'pay').click() + await page.getByLabel('Title').fill('Pay here') + await page.getByRole('button', { name: 'Save changes' }).click() + await page.keyboard.press('Escape') + + await writeDisk(page, `${ON_DISK}mail = process "Mail"\n`) + const dialog = page.getByRole('dialog', { name: 'The file changed on disk' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Keep mine' }).click() + await expect(dialog).toHaveCount(0) + await expect(shapes(page)).toHaveCount(2) + await expect(node(page, 'pay')).toContainText('Pay here') + + // Asked again at the next change, and this time the file wins. + await writeDisk(page, `${ON_DISK}mail = process "Mail"\nlog = process "Log"\n`) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: "Use the file's version" }).click() + await expect(shapes(page)).toHaveCount(4) + await expect(node(page, 'pay')).toContainText('Pay') + await expect(node(page, 'pay')).not.toContainText('Pay here') +}) + +test('a broken file on disk leaves the diagram alone, and says where', async ({ page }) => { + await writeDisk(page, 'title: Checkout\npay = process "Pay\n') + await expect( + page.getByText(/checkout\.flow changed on disk, but line \d+ has an error/), + ).toBeVisible() + await expect(shapes(page)).toHaveCount(2) +}) diff --git a/src/components/canvas/FlowEdge.vue b/src/components/canvas/FlowEdge.vue index 53cb93e..ccd3c94 100644 --- a/src/components/canvas/FlowEdge.vue +++ b/src/components/canvas/FlowEdge.vue @@ -1,6 +1,7 @@ + + diff --git a/src/composables/useDiagramFile.js b/src/composables/useDiagramFile.js index f6e82b8..db1c6ff 100644 --- a/src/composables/useDiagramFile.js +++ b/src/composables/useDiagramFile.js @@ -34,8 +34,13 @@ export function useDiagramFile({ bindKeys = false } = {}) { const canvas = useCanvasStore() const toasts = useToastStore() - /** @param {string} text @param {string} name @param {any} handle */ - function load(text, name, handle) { + /** + * @param {string} text + * @param {string} name + * @param {any} handle + * @param {number} [lastModified] + */ + function load(text, name, handle, lastModified = 0) { const { document: opened, errors } = parseFlow(text) if (!opened) { const [first] = errors @@ -49,7 +54,7 @@ export function useDiagramFile({ bindKeys = false } = {}) { canvas.forgetViewport() replace.mutate(opened, { onSuccess() { - file.remember(handle, name) + file.remember(handle, name, { flow: serialiseFlow(opened), lastModified }) toasts.push(`Opened ${name}`, { action: { label: 'Undo', run: undo } }) }, }) @@ -61,7 +66,7 @@ export function useDiagramFile({ bindKeys = false } = {}) { try { const [handle] = await picker({ types: PICKER_TYPES, multiple: false }) const picked = await handle.getFile() - load(await picked.text(), picked.name, handle) + load(await picked.text(), picked.name, handle, picked.lastModified) } catch (error) { if (/** @type {any} */ (error)?.name !== 'AbortError') { toasts.push('The file could not be opened.', { tone: 'danger' }) @@ -86,6 +91,7 @@ export function useDiagramFile({ bindKeys = false } = {}) { if (file.handle) { try { await write(file.handle, text) + file.markSaved(text, await lastModifiedOf(file.handle)) toasts.push(`Saved to ${file.name}`) return } catch { @@ -102,7 +108,10 @@ export function useDiagramFile({ bindKeys = false } = {}) { types: PICKER_TYPES, }) await write(handle, text) - file.remember(handle, handle.name) + file.remember(handle, handle.name, { + flow: text, + lastModified: await lastModifiedOf(handle), + }) toasts.push(`Saved to ${handle.name}`) } catch (error) { if (/** @type {any} */ (error)?.name !== 'AbortError') { @@ -145,3 +154,12 @@ async function write(handle, text) { await writable.write(text) await writable.close() } + +/** When a file was last written, or 0 when that cannot be read. @param {any} handle */ +async function lastModifiedOf(handle) { + try { + return (await handle.getFile()).lastModified + } catch { + return 0 + } +} diff --git a/src/composables/useLaunch.js b/src/composables/useLaunch.js index 7909e81..4fb8dce 100644 --- a/src/composables/useLaunch.js +++ b/src/composables/useLaunch.js @@ -33,7 +33,7 @@ export function useLaunch() { const [handle] = params.files ?? [] if (!handle) return const opened = await handle.getFile() - openText(await opened.text(), opened.name, handle) + openText(await opened.text(), opened.name, handle, opened.lastModified) }) watch( diff --git a/src/composables/useWatchFile.js b/src/composables/useWatchFile.js new file mode 100644 index 0000000..c29e00f --- /dev/null +++ b/src/composables/useWatchFile.js @@ -0,0 +1,119 @@ +import { onBeforeUnmount, onMounted } from 'vue' + +import { useFlowHistory } from '@/composables/useFlowHistory.js' +import { useFlowQuery } from '@/composables/useFlowQuery.js' +import { useReplaceDocument } from '@/composables/useNodeMutations.js' +import { diffDocuments } from '@/domain/diff.js' +import { parseFlow, serialiseFlow } from '@/domain/flowText.js' +import { useCanvasStore } from '@/stores/canvas.js' +import { useFileStore } from '@/stores/file.js' +import { useToastStore } from '@/stores/toasts.js' + +/** How often the open file is looked at. A browser cannot be told when it changes. */ +export const WATCH_MS = 1000 + +/** + * Changes to the open file made elsewhere (an agent writing through MCP, an + * editor) come in as they happen. With nothing unsaved here the file's version + * is shown, as one undoable change, with what changed marked on the canvas; + * with unsaved edits, the person is asked which to keep. The diagram's own + * saves are recognised and ignored. + */ +export function useWatchFile() { + const file = useFileStore() + const { document } = useFlowQuery() + const replace = useReplaceDocument('Reload from disk') + const { undo } = useFlowHistory() + const canvas = useCanvasStore() + const toasts = useToastStore() + let busy = false + /** @type {number | undefined} */ + let timer + + async function check() { + const handle = file.handle + if (busy || !handle || !document.value || window.document.hidden || canvas.fileConflict) return + busy = true + try { + const disk = await handle.getFile() + if (disk.lastModified === file.modified || handle !== file.handle) return + file.modified = disk.lastModified + + const { document: next, errors } = parseFlow(await disk.text()) + if (!next) { + const [first] = errors + toasts.push( + `${file.name} changed on disk, but line ${first.line} has an error: ${first.message} Yours is kept.`, + { tone: 'danger' }, + ) + return + } + + const flow = serialiseFlow(next) + if (flow === file.savedFlow) return + if (serialiseFlow(document.value) !== file.savedFlow) { + canvas.fileConflict = { name: file.name, document: next, flow } + return + } + show(next, flow) + } catch { + // Permission withdrawn, or the file moved away: there is nothing to watch. + } finally { + busy = false + } + } + + /** + * @param {import('@/domain/types.js').FlowDocument} next + * @param {string} flow + */ + function show(next, flow) { + const before = document.value + const name = file.name + replace.mutate(next, { + onSuccess() { + file.savedFlow = flow + if (before) canvas.flash(changesBetween(before, next)) + toasts.push(`${name} changed on disk. This is the new version.`, { + action: { label: 'Undo', run: undo }, + }) + }, + }) + } + + /** The file's version wins over what is unsaved here. */ + function takeTheirs() { + const conflict = canvas.fileConflict + canvas.fileConflict = null + if (conflict) show(conflict.document, conflict.flow) + } + + /** What is here wins; the next Save writes it over the file. */ + function keepMine() { + const conflict = canvas.fileConflict + canvas.fileConflict = null + if (conflict) file.savedFlow = conflict.flow + } + + onMounted(() => (timer = window.setInterval(check, WATCH_MS))) + onBeforeUnmount(() => window.clearInterval(timer)) + + return { takeTheirs, keepMine } +} + +/** + * What is new or different, to mark; what is gone has nothing left to mark. + * @param {import('@/domain/types.js').FlowDocument} before + * @param {import('@/domain/types.js').FlowDocument} after + * @returns {Map} + */ +export function changesBetween(before, after) { + const diff = diffDocuments(before, after) + return new Map([ + ...diff.nodes.added.map((id) => /** @type {const} */ ([id, 'added'])), + ...diff.nodes.changed.map((id) => /** @type {const} */ ([id, 'changed'])), + ...diff.nodes.moved.map((id) => /** @type {const} */ ([id, 'moved'])), + ...diff.edges.added.map((id) => /** @type {const} */ ([id, 'added'])), + ...diff.edges.changed.map((id) => /** @type {const} */ ([id, 'changed'])), + ]) +} diff --git a/src/stores/canvas.js b/src/stores/canvas.js index 2ed12d9..bd83209 100644 --- a/src/stores/canvas.js +++ b/src/stores/canvas.js @@ -4,6 +4,9 @@ import { defineStore } from 'pinia' import { STORAGE_KEYS } from '@/api/storageKeys.js' import { TOOL } from '@/domain/tools.js' +/** How long a change made on disk stays marked on the canvas. */ +export const FLASH_MS = 4000 + /** * A viewer's own habit, so it is kept in this browser, not in the diagram. On * unless turned off. @@ -124,6 +127,28 @@ export const useCanvasStore = defineStore('canvas', () => { exportAsked.value += 1 } + /** + * Shapes and connections that just changed on disk, marked for a few + * seconds so a person can see what an agent did. + * @type {import('vue').Ref>} + */ + const flashed = ref(new Map()) + /** @type {ReturnType | undefined} */ + let flashTimer + + /** @param {Map} changes */ + function flash(changes) { + clearTimeout(flashTimer) + flashed.value = changes + flashTimer = setTimeout(() => (flashed.value = new Map()), FLASH_MS) + } + + /** + * The open file changed on disk while there are edits here not saved to it. + * @type {import('vue').Ref<{ name: string, document: import('@/domain/types.js').FlowDocument, flow: string } | null>} + */ + const fileConflict = ref(null) + /** Zen mode: every tool hides until the pointer nears an edge. */ const zen = ref(false) @@ -184,6 +209,9 @@ export const useCanvasStore = defineStore('canvas', () => { requestView, exportFrame, exportAsked, + flashed, + flash, + fileConflict, requestExport, clearViewRequest, tool, diff --git a/src/stores/file.js b/src/stores/file.js index ab98934..54bcc1b 100644 --- a/src/stores/file.js +++ b/src/stores/file.js @@ -4,22 +4,44 @@ import { defineStore } from 'pinia' /** * The file the diagram was opened from or last saved to. The handle cannot be * stored, so it lasts for the visit; the diagram itself is saved regardless. + * + * What the file last held, as `.flow` text, and when it was last written, let + * a change on disk (an agent writing through MCP, an editor) be told from the + * diagram's own saves, and unsaved edits here from none. */ export const useFileStore = defineStore('file', () => { /** @type {import('vue').ShallowRef} a FileSystemFileHandle, where the browser has them */ const handle = shallowRef(null) const name = ref('') + /** The diagram as the file holds it, in the canonical `.flow` form. */ + const savedFlow = ref('') + /** The file's lastModified when it was last read or written here. */ + const modified = ref(0) - /** @param {any} next @param {string} fileName */ - function remember(next, fileName) { + /** + * @param {any} next + * @param {string} fileName + * @param {{ flow?: string, lastModified?: number }} [state] + */ + function remember(next, fileName, { flow = '', lastModified = 0 } = {}) { handle.value = next name.value = fileName + savedFlow.value = flow + modified.value = lastModified + } + + /** @param {string} flow @param {number} lastModified */ + function markSaved(flow, lastModified) { + savedFlow.value = flow + modified.value = lastModified } function forget() { handle.value = null name.value = '' + savedFlow.value = '' + modified.value = 0 } - return { handle, name, remember, forget } + return { handle, name, savedFlow, modified, remember, markSaved, forget } }) diff --git a/src/style.css b/src/style.css index d1c23c0..02eb458 100644 --- a/src/style.css +++ b/src/style.css @@ -447,3 +447,38 @@ optgroup { fill: var(--line); fill-opacity: 0.35; } + +/* + * Changed on disk, by an agent or an editor: a glow in the diff's colours that + * pulses a few times and fades, so a person sees what just happened. + */ +.flash { + --flash: var(--focus); + border-radius: 0.75rem; + animation: flash 1.2s var(--ease) 3; + box-shadow: 0 0 0 3px var(--flash); +} + +.flash-added { + --flash: #16a34a; +} + +.flash-changed { + --flash: #d97706; +} + +@keyframes flash { + 50% { + box-shadow: 0 0 0 9px color-mix(in srgb, var(--flash) 25%, transparent); + } +} + +.flash-edge.flash-added { + stroke: #16a34a !important; + stroke-width: 3 !important; +} + +.flash-edge.flash-changed { + stroke: #d97706 !important; + stroke-width: 3 !important; +} diff --git a/src/views/FlowView.vue b/src/views/FlowView.vue index f3a0360..ac83f7c 100644 --- a/src/views/FlowView.vue +++ b/src/views/FlowView.vue @@ -13,6 +13,7 @@ import HistoryControls from '@/components/shell/HistoryControls.vue' import MainMenu from '@/components/shell/MainMenu.vue' import ToolBar from '@/components/shell/ToolBar.vue' import CommandPalette from '@/components/shell/CommandPalette.vue' +import FileConflictDialog from '@/components/shell/FileConflictDialog.vue' import HelpDialog from '@/components/ui/HelpDialog.vue' import IconButton from '@/components/ui/IconButton.vue' import ToastHost from '@/components/ui/ToastHost.vue' @@ -24,6 +25,7 @@ import { PHONE, useMediaQuery } from '@/composables/useMediaQuery.js' import { useOpenSharedLink } from '@/composables/useShareLink.js' import { useLaunch } from '@/composables/useLaunch.js' import { useCommands } from '@/composables/useCommands.js' +import { useWatchFile } from '@/composables/useWatchFile.js' import { useFlowQuery } from '@/composables/useFlowQuery.js' import { useCanvasStore } from '@/stores/canvas.js' import { useToastStore } from '@/stores/toasts.js' @@ -39,6 +41,8 @@ const isExporting = ref(false) const isSharing = ref(false) useOpenSharedLink() useLaunch() +// An agent writing to the open file shows up here as it happens. +const fileWatch = useWatchFile() const { copyBrief } = useCopyBrief() const { document } = useFlowQuery() // Bound at the shell: a dialog that is not mounted cannot listen for its own key. @@ -213,6 +217,12 @@ onBeforeUnmount(() => window.removeEventListener('pointermove', onPointerMove)) +