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
4 changes: 4 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ The app, its skills and the toolkit carry one version.

Everything below is on `main` and reaches no install until a version is cut.

- **A mockup goes to the agent as its file.** Its **+** still shows a picture
of it in the chat, but the agent is handed the board's `.html` path rather
than that picture, since the file is what it reads and changes.

## v1.7.0

2026-09-26. The community projects open in the app, and the app no longer
Expand Down
60 changes: 51 additions & 9 deletions canvas/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ export function createAgentServer(options: {
!/^[A-Za-z0-9+/]*={0,2}$/.test(i.data)
)
return send(400, "bad image data");
if (
i.page !== undefined &&
(typeof i.page !== "object" ||
!["project", "community"].every(
(k) => i.page[k] === undefined || typeof i.page[k] === "string",
))
)
return send(400, "bad image page");
}
// The body cap above is the panel's limit in base64; a client that is not the
// panel meets the limit itself here, in the bytes the files come out as.
Expand Down Expand Up @@ -494,15 +502,49 @@ export function createAgentServer(options: {
fs.mkdirSync(keptOf(id), { recursive: true });
const imagesDir = images.length ? keptOf(id) : "";
const held: AgentImage[] = images.map(
(i: { n: number; name: string; type: string; data: string }) => ({
...i,
path: picture(
id,
`image-${i.n}`,
i.type,
Buffer.from(i.data, "base64"),
),
}),
(i: {
n: number;
name: string;
type: string;
data: string;
page?: { project?: string; community?: string };
}) => {
// A mockup's picture is kept for the panel, and the agent is pointed at its
// file, `<slug>/<file>.html` in the canvases of the project it was attached
// from, which need not be the one it is sent from. A community project's is not
// on this machine, so it keeps the name `sp fetch` finds it by.
const [slug, file, ...rest] = i.name.split("/");
const from = i.page?.project && projects().get(i.page.project);
const local =
i.page?.community === undefined &&
(i.page?.project === undefined || from) &&
rest.length === 0 &&
SAFE_NAME.test(slug) &&
SAFE_NAME.test(file ?? "");
return {
...i,
path: picture(
id,
`image-${i.n}`,
i.type,
Buffer.from(i.data, "base64"),
),
page:
i.page &&
(local
? path.join(
folderOf(
from ? path.join(from, CANVASES) : examplesDir,
examplesDir,
slug,
),
file,
)
: i.page.community
? `${i.name} of the community project ${i.page.community}`
: i.name),
};
},
);
const c = command(
def.bin,
Expand Down
23 changes: 16 additions & 7 deletions canvas/src/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ const OPEN_KEY = "sp-chat-open";

/**
* What the canvas hands the chat panel when the button is pressed (canvasAttach.tsx): a picture
* to attach to the message, or the reason none was. A board comes over as a picture too. The
* file's own name says which board it is, and the panel shows it under the tile. And the start
* to attach to the message, or the reason none was. A board comes over as a picture too, for its
* tile only: the agent is handed the board's file, which its name says, and the panel shows that
* name under the tile. And the start
* of a message, from the strip's "+" (CanvasStrip.tsx), because a canvas is only ever the
* agent's work, and a folder with no boards in it is not one. And a whole message, sent as it is,
* from the new-project dialog (AppShell.tsx), which starts the agent defining the product.
Expand All @@ -104,7 +105,8 @@ const OPEN_KEY = "sp-chat-open";
export const CANVAS_ATTACH = "sp:canvas-attach";

export type CanvasAttachDetail =
| { kind: "board"; name: string; src: string }
/** `page` for a mockup, whose drawing is only the tile's: the agent is handed its file. */
| { kind: "board"; name: string; src: string; page?: true }
| { kind: "image"; file: File }
| { kind: "error"; message: string }
| { kind: "draft"; text: string }
Expand Down Expand Up @@ -249,6 +251,9 @@ interface Attached {
url: string;
/** A board still being drawn, with no `url` yet, or one whose drawing failed. */
state?: "pending" | "failed";
/** A mockup, and the project it was attached from, which the panel may since have left: the
* agent gets its file there rather than this picture of it (agents.ts). */
page?: { project?: string; community?: string };
}

/**
Expand Down Expand Up @@ -713,6 +718,7 @@ export function ChatPanel(props: {
type: r.file.type,
size: r.file.size,
url: r.url,
page: t.page,
}
: t;
});
Expand Down Expand Up @@ -758,7 +764,7 @@ export function ChatPanel(props: {
* drawing asked of the server, to land in that tile. Asked for again it keeps the tile it has —
* one already there or on its way is only named again, and one that failed is drawn again.
*/
const addBoard = (name: string, src: string) => {
const addBoard = (name: string, src: string, page?: true) => {
let tile = tray.current.find((t) => t.name === name);
if (!tile && tray.current.length >= MAX_IMAGES)
return setSendError(
Expand All @@ -779,6 +785,7 @@ export function ChatPanel(props: {
size: 0,
url: "",
state: "pending",
page: page && { project, community },
};
const next = tile;
tray.current = [...tray.current.filter((t) => t.n !== next.n), next].sort(
Expand All @@ -805,7 +812,8 @@ export function ChatPanel(props: {
// What the buttons on a canvas shape hand over (canvasAttach.tsx): a picture, attached and named
// in the sentence, or the reason there is none. A mockup arrives as a picture of itself, called
// by its own path, so pointing at one puts the same tile and the same number in the panel that
// pointing at a picture does — and the path is what the tile is captioned with.
// pointing at a picture does — and the path is what the tile is captioned with, and all the
// agent is given of it.
// No dependency list, so every render leaves a listener holding that render's `addImages` and
// its numbering — a listener that stayed would be attaching to the draft the panel had at mount.
useEffect(() => {
Expand All @@ -819,7 +827,7 @@ export function ChatPanel(props: {
// that is still being read.
if (detail.kind === "board") {
adds.current = adds.current.then(() =>
addBoard(detail.name, detail.src),
addBoard(detail.name, detail.src, detail.page),
);
return;
}
Expand Down Expand Up @@ -937,11 +945,12 @@ export function ChatPanel(props: {
agent,
model,
effort,
images: attached.map(({ n, name, type, url }) => ({
images: attached.map(({ n, name, type, url, page }) => ({
n,
name,
type,
data: url.slice(url.indexOf(",") + 1),
page,
})),
}),
});
Expand Down
22 changes: 22 additions & 0 deletions canvas/src/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,28 @@ describe("AGENTS", () => {
);
});

// A mockup's picture is only the panel's tile: the agent is pointed at its file instead.
it("hands a mockup over as its file, not its picture", () => {
const board = {
n: 2,
name: "shop/01-home.html",
type: "image/png",
data: "CCC",
path: "/tmp/sp-chat-r/2.png",
page: "/proj/canvases/shop/01-home.html",
};
expect(
JSON.parse(def("claude").stdin("tighten #2", "P", [board])).message
.content,
).toEqual([
{ type: "text", text: "[Image #2] /proj/canvases/shop/01-home.html" },
{ type: "text", text: "tighten #2" },
]);
expect(def("codex").stdin("tighten #2", "P", [board])).toBe(
"P\n\n[Image #2] /proj/canvases/shop/01-home.html\n\ntighten #2",
);
});

// A session is what the agent called it on its first turn, and every later turn resumes that.
it("reads the session off the first turn and resumes it on the next", () => {
expect(
Expand Down
23 changes: 15 additions & 8 deletions canvas/src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export interface AgentImage {
/** Where the server wrote it, for an agent that takes files rather than bytes; gone once
* that agent has exited. */
path: string;
/** A mockup's HTML file, when the picture is only the panel's drawing of one: the agent is
* pointed at the file and not handed the picture, since the file is what it reads and edits. */
page?: string;
}

/** What the composer chose, handed to `args`. An empty string means the CLI decides. */
Expand Down Expand Up @@ -199,13 +202,17 @@ export const AGENTS: AgentDef[] = [
// `[Image #2]` is the marker Claude Code writes itself when a screenshot is pasted into its
// terminal, so the number arrives as something already read rather than a local convention.
stdin: (message, _preamble, images) => {
const blocks = images.flatMap((i) => [
{ type: "text", text: `[Image #${i.n}] ${i.name}` },
{
type: "image",
source: { type: "base64", media_type: i.type, data: i.data },
},
]);
const blocks = images.flatMap((i) =>
i.page
? [{ type: "text", text: `[Image #${i.n}] ${i.page}` }]
: [
{ type: "text", text: `[Image #${i.n}] ${i.name}` },
{
type: "image",
source: { type: "base64", media_type: i.type, data: i.data },
},
],
);
const content = blocks.length
? [...blocks, { type: "text", text: message }]
: message;
Expand Down Expand Up @@ -308,7 +315,7 @@ export const AGENTS: AgentDef[] = [
stdin: (message, preamble, images) =>
[
preamble,
images.map((i) => `[Image #${i.n}] ${i.path}`).join("\n"),
images.map((i) => `[Image #${i.n}] ${i.page ?? i.path}`).join("\n"),
message,
]
.filter(Boolean)
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/canvasAttach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async function attach(editor: Editor, target: TLShape) {
`&w=${Math.max(1, Math.round(w * scale))}&h=${Math.max(1, Math.round(h * scale))}`,
window.location.href,
).href;
return dispatchAttach({ kind: "board", name, src });
return dispatchAttach({ kind: "board", name, src, page: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Add an Unreleased note for mockup attachments

The agent now receives a mockup's HTML file instead of its picture. Contributing rules require a line under ## Unreleased for user-visible changes, but this PR adds none.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2ed1ab8.

}
// One of the person's own: whatever it is, the agent gets a picture of it, named by where it
// reads the thing itself (canvasContent.ts).
Expand Down
Loading