Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 34 additions & 5 deletions backlog/tasks/fl-87 - See-the-agent-edits-live.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,8 +27,35 @@ When the open .flow file changes on disk (an agent writing through MCP or an edi

<!-- AC:BEGIN -->

- [ ] #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

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

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.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

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.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

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.
<!-- SECTION:FINAL_SUMMARY:END -->
102 changes: 102 additions & 0 deletions e2e/live.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
7 changes: 6 additions & 1 deletion src/components/canvas/FlowEdge.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup>
import { computed, inject, ref } from 'vue'
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core'
import { useCanvasStore } from '@/stores/canvas.js'

/** Carries its own remove control, since an edge is otherwise only deletable by key. */
const props = defineProps({
Expand Down Expand Up @@ -28,6 +29,10 @@ const props = defineProps({
targetNode: { type: Object, default: null },
})

/** Just changed on disk: marked for a few seconds. */
const canvasStore = useCanvasStore()
const flash = computed(() => canvasStore.flashed.get(props.id) ?? '')

/** @type {(edgeId: string) => void} */
const detach = inject(DETACH_EDGE, () => {})

Expand Down Expand Up @@ -90,7 +95,7 @@ import { sketchPath } from '@/domain/sketch.js'
:marker-end="arrow"
:marker-start="both ? arrow : undefined"
:style="{ strokeWidth: selected ? 2.5 : 1.5, strokeDasharray: dashed ? '6 4' : undefined }"
:class="selected ? 'stroke-focus' : ''"
:class="[selected ? 'stroke-focus' : '', flash ? `flash-edge flash-${flash}` : '']"
/>

<!-- A wide transparent path, so the thin line is easy to hit. -->
Expand Down
6 changes: 6 additions & 0 deletions src/components/canvas/FlowNodeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CONNECT_STATE } from './connectKey.js'
import { EDIT_TEXT } from './editKey.js'
import InlineText from './InlineText.vue'
import { FIELD_LIMIT } from '@/domain/validators.js'
import { useCanvasStore } from '@/stores/canvas.js'

/** One card for every shape: the registry supplies the meaning, `shapes.js` the outline. */
const props = defineProps({
Expand Down Expand Up @@ -83,6 +84,9 @@ const shiftHeld = useShiftKey()
function onResizeEnd({ params }) {
resize(props.id, params)
}
/** Just changed on disk, by an agent or an editor: marked for a few seconds. */
const canvasStore = useCanvasStore()
const flash = computed(() => canvasStore.flashed.get(props.id) ?? '')
const isText = computed(() => node.value.type === SHAPE.TEXT)
/** A frame: a region behind the shapes it holds, named at its top left. */
const isFrame = computed(() => node.value.type === SHAPE.FRAME)
Expand Down Expand Up @@ -118,7 +122,9 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ?
isDropTarget && !acceptsDrop ? 'opacity-40' : '',
isKeyboardFocused ? 'outline-2 outline-offset-4 outline-focus' : '',
meta.openable ? 'cursor-pointer' : 'cursor-default',
flash ? `flash flash-${flash}` : '',
]"
:data-flash="flash || undefined"
:aria-current="isKeyboardFocused ? 'true' : undefined"
:style="{
// The wrapper carries the size, so a resize handle can change it.
Expand Down
6 changes: 5 additions & 1 deletion src/components/canvas/__tests__/FlowNodeCard.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'

import diagram from '@/tests/fixtures/diagram.json'
import { SHAPE } from '@/domain/constants.js'
Expand All @@ -12,7 +13,10 @@ const nodes = Object.fromEntries(diagram.nodes.map((raw) => [String(raw.id), nor
const global = { stubs: { Handle: true } }

const render = (node, props = {}) =>
mount(FlowNodeCard, { props: { id: node.id, data: { node }, ...props }, global })
mount(FlowNodeCard, {
props: { id: node.id, data: { node }, ...props },
global: { ...global, plugins: [createPinia()] },
})

describe('FlowNodeCard', () => {
it('shows the title and a truncated description', () => {
Expand Down
38 changes: 38 additions & 0 deletions src/components/shell/FileConflictDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup>
import BaseModal from '@/components/ui/BaseModal.vue'

/** The open file changed on disk while there are edits here not saved to it. */
defineProps({ name: { type: String, required: true } })
const emit = defineEmits(['theirs', 'mine'])
</script>

<template>
<BaseModal title="The file changed on disk" @close="emit('mine')">
<div class="space-y-4 px-5 py-4">
<p class="text-sm">
{{ name }} was changed outside isketch, perhaps by an agent, and you have edits here that
are not saved to it.
</p>
<p class="text-xs text-muted">
Taking the file's version can be undone. Keeping yours leaves the file as it is until you
save.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-lg border border-line px-3 py-2 text-sm transition-colors hover:bg-hover"
@click="emit('mine')"
>
Keep mine
</button>
<button
type="button"
class="rounded-lg bg-brand px-3 py-2 text-sm font-medium text-brand-ink transition-opacity hover:opacity-90"
@click="emit('theirs')"
>
Use the file's version
</button>
</div>
</div>
</BaseModal>
</template>
28 changes: 23 additions & 5 deletions src/composables/useDiagramFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } })
},
})
Expand All @@ -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' })
Expand All @@ -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 {
Expand All @@ -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') {
Expand Down Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion src/composables/useLaunch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading