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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and

## Unreleased

### Added

- Editor: `sca cell ls|get|put|insert|rm|run` edits the open notebook's cells through the
running editor (found by `<workspace>/.sca-editor/server.json`): the page shows the change live,
with no reload, the cell runs, and the command prints its status, errors and output. `sca new
NAME [--kind part|assembly]` writes a notebook the editor then lists, leaving the person's view
alone. The routes behind them are `GET /api/cells` and `POST /api/cells/{edit,insert,delete,run}`.
- Editor: a change to the open notebook's file made by someone else (an agent) is noticed
within a second and applied cell by cell (edited cells are updated and run again, added or
removed cells appear or go), without a reload; a changed setup or `@app.function` cell restarts
the kernel. Notebooks added to or removed from the workspace appear in the list by themselves.

### Fixed

- Editor: an edit made in the editor no longer writes over what an agent saved to the same
notebook file. If a cell was edited in both places, the file's version is kept and the
editor's unsaved text goes to the log.

## 2.2.2 — 2026-10-14

### Added
Expand Down
3 changes: 3 additions & 0 deletions docs/landing.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ each instruction to one action and wait for them.
these edges by 1 mm"). You read the selection from the file (`selection` cell) or with
`sca snapshot`, and you can look at `.sca-editor/viewport.png` to see what they see. Then
you edit the notebook; they see your change in the editor.
- **Work through the editor.** Change cells with `sca cell` (`sca cell ls`, `put`, `insert`):
the page updates live and nothing the person typed is overwritten. Editing the file works
too: the editor takes the change in within a second.
- **"Change this cell."** Every cell has a copy button that copies a reference such as
`/path/u_link.py:L47-L48 (cell 02 ...)`. They paste it to you with what they want changed,
and you edit exactly those lines.
Expand Down
6 changes: 4 additions & 2 deletions docs/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ Paths are relative to this file's directory.
selected, and you MUST read
`references/domains/editor-collaboration.md` first to see what they
see (selection cell, `sca snapshot`, cell references) and how to
hand the work back.
hand the work back. While the editor runs, change its notebooks with
`sca cell` (or the file; never both blindly): the editor is the
file's writer.
- Creating, installing, updating, or removing a SimpleCADAPI addon (a
third-party skill+tooling package around `.scadpkg`) enters no
workflow; you MUST use `references/domains/addon-development.md`
Expand Down Expand Up @@ -202,7 +204,7 @@ print(len(rebuilt))
- `references/inspect/brep-reverse-engineering.md`
- `references/domains/addon-development.md` — `sca` addon CLI and authoring guide
- `references/domains/editor-collaboration.md` — working with a person in `sca edit`:
selection cell, snapshot, cell references, parameters
selection cell, snapshot, cell references, `sca cell` / `sca new`, parameters
- `references/docs/guides/reconstruction-agent-test-prompt.md`
- `references/docs/guides/reconstruction-agent-strategy.md`
- `references/docs/guides/notebook-runtime.md`
Expand Down
48 changes: 44 additions & 4 deletions docs/skill/references/domains/editor-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,26 @@ edit code. It is not a workflow. The modeling you do still follows the workflow
you selected; this page says how to see what the person sees and how to hand
work back.

The notebook file is the only shared state. The editor autosaves every edit to
The notebook file is the shared state. The editor autosaves every edit to
it, and every message the person gives you (a selection, a cell reference, a
tuned value) is also something you can read from disk. Never rely on what the
person says they selected when the file or the snapshot can tell you.

The editor holds the open notebook in memory and is the one writer of its
file. You have two safe ways to change it while it runs; use the first:

1. **`sca cell` (preferred).** It edits through the editor: the page shows the
change live (no reload, no flicker), the cell runs, and you get its state back.
2. **Edit the file.** The editor notices a saved change within a second, compares
it with its cells and applies only what differs (changed cells are edited in
place and run again, added or removed cells appear or go away). A change to the
setup cell or an `@app.function` cell restarts the kernel instead. A file that
does not parse is ignored until the next save that does.

If the person has the same cell open with unsaved typing, **your version wins**:
their text is dropped and kept in the editor's log for them. Do not rewrite a
cell the person is typing in without telling them.

## Start the editor for a person

```bash
Expand All @@ -28,6 +43,29 @@ sca edit <workspace> --no-browser --port 7180
the assembly that fixes them together) to `./u_link_motor_mount`, then
`sca edit ./u_link_motor_mount`.

## Edit cells through the editor: `sca cell`

```bash
sca cell ls [--code] # numbered cells with status, errors and output
sca cell get 3 # one cell: code, status, errors, output
echo 'L = 90' | sca cell put 3 # replace the code of cell 3 and run it (--no-run to only save)
sca cell insert --after 3 -f new.py # add a cell (--after start|end|CELL, --name fn_name)
sca cell rm 4
sca cell run [CELL ...] # run cells (default all) and what they feed
sca new bracket --kind part # a new notebook; the open editor lists it, the person's view stays
```

- A cell is named by its number in `sca cell ls`, its `key`, its function name, or its id.
Numbers move when cells are added or removed: read the list the command prints back.
- Each write prints the touched cell's state (`status`, `errors`, `output`). That is the
feedback: read it before the next step, as with any other operation.
- `put` fails with a conflict if you pass a base that is out of date; without one it
replaces the cell, so `get` first when the person may have changed it.
- The commands find the running editor through `<workspace>/.sca-editor/server.json`. With no
editor running, or with another notebook open, `ls` and `get` read the file and the write
commands refuse: then edit the file directly, nothing else holds it.
- `@app.function` cells cannot be changed this way; edit them in the file.

## Read what the person is doing

| Need | Where |
Expand Down Expand Up @@ -140,10 +178,12 @@ file first, since every cell is blocked until it compiles.

## Rules of thumb

- Re-read the file before editing: the person edits and autosaves too, and the
editor rewrites the selection cell.
- Edit by cell. A change that touches many cells at once makes the person's
- Re-read the cell before editing (`sca cell get`, or the file): the person edits and
autosaves too, and the editor rewrites the selection cell.
- Edit by cell, with `sca cell`. A change that touches many cells at once makes the person's
screen reflow and loses their place.
- New notebooks: `sca new`, not a bare file write plus a request to refresh; the editor
lists a new file by itself either way.
- After an edit, say which cells you changed (by reference) and what to look
at, so the person can run them and judge the result.
- Never edit `__marimo__/` or `.sca-editor/`; both are generated.
Expand Down
13 changes: 13 additions & 0 deletions editor-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ export function App() {
setCells(next);
log.knowCells(next);
});
// A notebook was added or removed under the workspace (an agent wrote one).
socket.on("workspace.changed", () => refreshWorkspace());
// The server dropped an edit made on a cell that had changed on disk.
socket.on("cell.conflict", (envelope) => {
const mine = String(envelope["mine"] ?? "");
log.push("error", `a cell changed on disk while you were editing it: kept the file's version. Your unsaved text was:\n${mine.length > 600 ? `${mine.slice(0, 600)}…` : mine}`);
});
socket.on("notebook.features", (envelope) => setFeatures((envelope["features"] as NotebookFeature[]) ?? []));

socket.on("preview", (envelope) => {
Expand Down Expand Up @@ -355,6 +362,12 @@ export function App() {
socket={socketRef.current}
notebook={notebook}
onInterrupt={handleStop}
onConflict={(cell, mine) =>
log.push(
"error",
`${cell.title ?? "cell"} changed on disk while you were editing it: kept the file's version. Your unsaved text was:\n${mine.length > 600 ? `${mine.slice(0, 600)}…` : mine}`,
)
}
/>
</TabsContent>
<TabsContent value="workspace" className="min-h-0">
Expand Down
56 changes: 54 additions & 2 deletions editor-web/src/components/CellEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,16 @@ export function CellEditor({
socket,
notebook,
onInterrupt,
onConflict,
}: {
cells: NotebookCell[];
features: NotebookFeature[];
params: ParamMeta[];
socket: SocketLike;
notebook: string | null;
onInterrupt: () => void;
/** A cell changed on disk while it had an unsaved edit here: the file's version was kept. */
onConflict: (cell: NotebookCell, mine: string) => void;
}) {
const [drafts, setDrafts] = useState<Map<string, string>>(new Map());
const [running, setRunning] = useState<string | null>(null);
Expand All @@ -89,14 +92,60 @@ export function CellEditor({
// the second; any click elsewhere disarms.
const [armed, setArmed] = useState<string | null>(null);
const saveTimers = useRef(new Map<string, number>());
// What each edited cell's draft started from, and the last text sent to be saved:
// a cell that arrives as something else was changed by someone else (an agent).
const bases = useRef(new Map<string, string>());
const sent = useRef(new Map<string, string>());

const featureByCell = useMemo(() => new Map(features.map((feature) => [feature.cell, feature])), [features]);

useEffect(() => {
setCollapsed(loadCollapsed(notebook));
setDrafts(new Map());
bases.current.clear();
sent.current.clear();
}, [notebook]);

// The cells arrive again after every save and every change on disk: settle each draft
// against them. Saved (equal) -> done; confirms our last save -> keep editing from it;
// anything else -> the file's version wins, the person is told.
const onConflictRef = useRef(onConflict);
onConflictRef.current = onConflict;
useEffect(() => {
const same = (a: string, b: string) => a.trim() === b.trim();
const lost: [NotebookCell, string][] = [];
setDrafts((previous) => {
let next: Map<string, string> | null = null;
const drop = (id: string) => {
next ??= new Map(previous);
next.delete(id);
bases.current.delete(id);
sent.current.delete(id);
};
for (const [id, draft] of previous) {
const cell = cells.find((candidate) => candidate.id === id);
if (!cell) {
drop(id);
continue;
}
const base = bases.current.get(id);
const last = sent.current.get(id);
if (same(cell.code, draft)) drop(id);
else if (last !== undefined && same(cell.code, last)) bases.current.set(id, cell.code);
else if (base !== undefined && !same(cell.code, base)) {
lost.push([cell, draft]);
drop(id);
}
}
return next ?? previous;
});
for (const [cell, mine] of lost) {
cancelSave(cell.id);
onConflictRef.current(cell, mine);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cells]);

const remember = (next: Set<string>) => {
setCollapsed(next);
try {
Expand All @@ -122,7 +171,8 @@ export function CellEditor({
cancelSave(cell.id);
const code = drafts.get(cell.id) ?? cell.code;
setRunning(cell.id);
socket?.send({ type: "code.edit", cell: cell.id, code });
sent.current.set(cell.id, code);
socket?.send({ type: "code.edit", cell: cell.id, code, base: bases.current.get(cell.id) });
window.setTimeout(() => setRunning((current) => (current === cell.id ? null : current)), 2500);
};

Expand All @@ -141,14 +191,16 @@ export function CellEditor({
const insert = (after: string | null) => socket?.send({ type: "insert.request", after });

const edit = (cell: NotebookCell, value: string) => {
if (!bases.current.has(cell.id)) bases.current.set(cell.id, cell.code);
setDrafts((previous) => new Map(previous).set(cell.id, value));
if (cell.readonly) return;
cancelSave(cell.id);
saveTimers.current.set(
cell.id,
window.setTimeout(() => {
saveTimers.current.delete(cell.id);
socket?.send({ type: "cell.save", cell: cell.id, code: value });
sent.current.set(cell.id, value);
socket?.send({ type: "cell.save", cell: cell.id, code: value, base: bases.current.get(cell.id) });
}, AUTOSAVE_MS),
);
};
Expand Down
Loading
Loading