Skip to content

Publish from chat - #149

Merged
jelveh merged 42 commits into
mainfrom
publish-from-chat
Nov 3, 2026
Merged

jelveh merged 42 commits into
mainfrom
publish-from-chat

Conversation

@jelveh

@jelveh jelveh commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #147.

What it does

Users can now publish from the chat. When they ask the AI to publish ("put it online", "make it live"), it calls a new OfferPublish tool, which puts a compact Publish card in the chat. The publish only happens when the user clicks it, so the AI can't make a site public on its own, or because a web page or tool result told it to.

The card shows the project's publish state, the same as the toolbar button:

State Card
Never published [bakery-orders].puter.site Publish (the address is editable; the AI suggests one)
Live, with changes bakery-orders.puter.site Publish changes
Live, up to date ● Live bakery-orders.puter.site
  • The button stays disabled until the AI's reply finishes (the popover follows the same rule).
  • It shows "Publishing…" while the publish runs.
  • Errors appear inline under the field ("That address is taken. Try another.").
  • Every card in the chat updates together. A reloaded chat shows its cards again, using the current state.

Refactor

doPublish() is now the popover's thin wrapper around a new publishProject(ui), which holds the whole flow: guards, saving unsaved code, busy state, release staging, hosting, records, the key note and analytics. Each surface supplies render / desiredName / start / success / fail hooks:

  • Popover: the same code as before, moved into its hooks, so its behavior is unchanged (progress and share views, toasts, alerts, focus).
  • Card: inline states and short errors, and the Project Published event carries source: 'chat'. The popover's event has no source, as before.

Other changes

  • Prompt: the system prompt and the publish_site description now send a publish request to OfferPublish. Everything else about the preview stays as it was.
  • OfferPublish: added to PREVIEW_SAFE_TOOLS (it writes no files). It isn't strict, which keeps the strict-tool count under Claude's limit of 20.

Tests

  • New scripts/test-publish-card.mjs runs the real card code in a mini DOM. It covers each state, waiting during a turn, keeping the typed address, click/Enter, busy, errors, success, stale turns and reload, plus the wiring.
  • test-publish-analytics drives the real publishProject with card hooks: the hook order, source: 'chat', and a failure that calls fail with no alert.
  • Updated the VM tests that slice doPublish so they also load publishProject, and updated three text assertions that pinned the old line layout.
  • npm test passes and vite build succeeds.

Review fixes

Each fix is its own commit, with a regression test that fails without it.

Reliability

  • A message sent while the site's address was changing started a turn, and the "site moved" note for the AI landed in the middle of it. A send now waits for its project's address change, with a toast.
  • The popover's Change address could overlap a publish that was still saving Code-view edits, so the publish could update a deleted address or retire the release the new address pointed at. The address change now waits for it.
  • The Settings dialog's rename didn't wait for a toolbar or chat address change, so both could create a new address and one was left registered and unused. It now waits.
  • Address changes are tracked per project. Before, one project's change blocked another project's Publish, showed "Changing…" on its card, and refused its own address change.

Publish card UX and accessibility

  • Pressing Publish with the keyboard dropped focus to the page when the card redrew. Focus now stays in the card: on the card while publishing, then on the site's link.
  • A failed publish no longer pulls focus out of the message box if the user is typing their next message.
  • If the user opened another project while a card publish ran, a failure was silent. It now shows as an error toast. A user who came back mid-publish now sees the outcome on the card that is on screen.
  • On phones the address field was 13px, which makes iOS zoom the page. It is now 16px, matching its suffix.
  • In a narrow card, long addresses were cut off against .puter.site ("bakery-ord.puter.site"). Below 440px the button now goes under the field (container query). Wider cards are unchanged.
  • The field's error and the address-change warning are tied to the field (aria-invalid, aria-describedby).

Analytics

  • A chat address change that finished after a project switch wasn't counted. It now is, as the popover's is.

Second review round

Each fix is its own commit, with a test that fails without it.

What the AI is told

  • A stopped build can leave a tool call without its result. A publish note added after it put the result the next send fills in after the note, splitting the call from its result. The model API rejects that request, and every send after it. Missing results are now filled in before a note goes in.
  • The Settings dialog's rename moved the site but left no note, so the AI kept giving out the old address, which no longer works. It now leaves the same note as the toolbar and the chat. It also waits for the open project's turn or restore, so the note never lands mid-turn.
  • A publish or a chat address change that finished after the user opened another project dropped its note. The note now goes into that project's saved conversation.

Publish card

  • An address change from a card that finished in another project wasn't recorded on the card. Back in the project, the card offered to move the site to the address it already had. The card's outcome is now saved to its own project. publishProject has a new optional away hook for this.
  • A failed publish's error came back the next time the site had changes, even after a publish from the toolbar had worked. An error now clears when another publish starts or the card's state changes.
  • The address field drew two focus rings (its border plus the global input ring around the bare input). It now draws one: the border with a soft ring, in light and dark.
  • When Publish first saves Code view edits, its focused button is disabled in place, which dropped keyboard focus to the page. The card now keeps focus.

npm test passes and vite build succeeds.

Third review round

Each fix is its own commit, with a test that fails without it.

What the AI is told

  • Duplicating a published project copied the notes telling the AI the site is public at the original's address. The copy starts unpublished, but its AI gave that address out as the copy's. The copy now gets a note saying the address belongs to the original. Its Publish cards drop the original's publish record, so none of them reads "Published".
  • If the AI called OfferPublish before an edit in the same reply, it was told the site was up to date with nothing to publish. The card then offered Publish changes once the reply ended. A change still to come in the reply now counts.

Regressions

  • Settings → Rename: saving the address unchanged during a build was refused with "Finishing the current task" instead of closing the editor, as it did on main. The unchanged check now runs before the waits. (The test's stand-in for the editor's redraw also threw, which hid this. It's fixed.)

UI

  • An older card's crossed-out address was #8a8a8a on #fafafa (3.3:1), under AA for 13px text. It now uses the #6b6b6b of the "Published" label beside it (5.1:1).
  • When the popover's Change address was refused during a turn, every press of Save or Enter added another "Finishing the current task" toast. It now shares the 3s throttle of the other wait toasts.

npm test passes and vite build succeeds.

Fourth review round

Each fix is its own commit, with a test that fails without it.

What the AI is told

  • A chat keeps the system prompt it started with, so projects started before this PR never heard of OfferPublish or OfferAddressChange. Their prompt now gets the new sentences as a note, the way the required opening line reaches older chats.
  • publish_site's description said never to share a public link, and the publish notes only said where the site is. The AI now keeps the link private until the user publishes, and each note says it can give out the address.
  • Only the toolbar's Publish panel can remove the "Made with Puter" badge. OfferUpgrade said "open Publish", which now matches two buttons. It names the toolbar's Publish, and OfferPublish's description says its button has no badge option.
  • The address the AI passes was reshaped like a title: dots became hyphens and it was cut at 30 characters ("joes-bakery.puter.site" became joes-bakery-puter-site). Now https:// and .puter.site are dropped and a valid address is kept whole.

Reliability

  • In a project published once and then opened without a preview, the card's Publish shipped the whole project folder. The toolbar and Settings refuse in that state. OfferPublish now needs the preview, and an older card waits.
  • A copy of a published project offered the original's address in its Publish card. That address is taken, so Publish could only fail. The copied cards' addresses are cleared.

Publish card UX and accessibility

  • On a 320px phone, a 30-character address was cut mid-letter against .puter.site and read as a different address. It now ends with an ellipsis.
  • Enter in the field while the button waited for the AI's reply did nothing, and gave no reason. It now shows the same wait toast as the rest of the app.
  • Screen readers now hear that an older card's crossed-out address no longer works.
  • A cut-off address shows in full on hover, as the popover's link does.

Analytics

  • Settings' rename didn't send Site Address Changed. Every address change now sends it, with source toolbar, chat or settings.

Tests
Mutating the code showed several tests passed with it broken, and they now catch it:

  • the address change refusing during a publish or with missing files;
  • the publishCardUrl strip, which now runs prepareHistoryForAI;
  • a busy refusal's words;
  • Settings waiting for an address change;
  • publishProject waiting for one and clearing busy before a failure, which now run the real publishProject;
  • a copy reading the open project's live address.

A text check that pinned argument order now checks each argument.

npm test passes and vite build succeeds.

jelveh added 30 commits November 3, 2026 11:06
When the user asks the AI to publish, it calls the new OfferPublish tool,
which puts a Publish card in the chat. The user publishes with one click;
the AI can never make a site public on its own.

The card draws the project's publish state, like the toolbar button:
an address field and Publish before the first publish, Publish changes when
the live site is behind, and Live with the link once it's up to date. Its
button waits while a turn runs, and errors show inline beside the field.

doPublish is split: publishProject(ui) holds the whole publish (guards, busy
state, release, hosting, records, analytics), and the popover and the card
each pass their own render/start/success/fail hooks. The popover's behavior
is unchanged. A publish from the card is tracked with source: 'chat'.
Only the newest Publish card in the chat is a control now. An older card
stops following the project's state: one the user published from stays as
a "Published" line with the address it went to (saved on its tool result as
publishCardUrl, never sent to the model), and one that wasn't used is
hidden, since a newer card replaced it.

A publish, from the card or the toolbar, now adds a hidden note to the
conversation saying the site is live and where. Before, the model's last
word on it could be the OfferPublish result saying nothing is public yet.
The AI can now offer an address change: when the user asks, it calls the
new OfferAddressChange tool, which puts the Publish card in its Change
address mode in the chat, with the new address (editable), the address it
moves from, and a note that links to the old one stop working. The user
moves the site with one click; the AI can't do it on its own.

The popover's address change is now changePublishedAddress in ui.js, used
by the popover and the card. It also:
- redraws the toolbar button and the chat cards, which kept showing the
  old, deleted address
- leaves a hidden note telling the AI the site moved and the old address
  no longer works
- waits while a turn or a publish runs (the note can't land mid-turn, and a
  publish would update the address being moved), and a publish waits for it

An older card that published to an address since moved shows that address
as plain text, no longer a link to a site that is gone.
A message sent while the published site was moving to a new address
started a turn, and the change's note for the AI ("the site moved, the
old address no longer works") then landed in the middle of that turn,
which also worked with the old address. The change only checked for a
turn already running when it began.

A send now waits for an address change of its project, with a toast, the
same as it waits for a publish. The address change records which project
it is for, so a send in another project doesn't wait.
A publish with unsaved edits in the Code view saves them before it
starts, and the popover's Change address could run in that moment: the
address change only waited for a publish already copying files. The two
then overlapped, so the publish could update the old address after it was
deleted, or retire the release the new address had just been pointed at.

The address change now also waits for a publish that is still saving.
The card redraws its markup when a publish starts and when it finishes.
That replaced the focused Publish button, so focus dropped to the page: a
keyboard user who pressed Enter on Publish was left with nothing focused,
and their next Tab started from the top of the page.

When focus was in the card, a redraw now gives it back: to the address
field if that had it, else the button, or the site's link once it's live.
While the button is disabled for the publish, the card itself holds focus
(no focus ring, like the Publish popover). Focus anywhere else is left
alone.

The test DOM now moves focus to <body> when the focused element is
removed, as browsers do.
A failed publish or address change from the card always focused its
address field. Publishing takes a few seconds, and the user may have
started typing their next message meanwhile: the failure moved their
cursor out of the message box mid-sentence.

The error still shows beside the field and is read out, but the field
only takes focus from the card itself or from nowhere.
A publish or an address change from the card shows how it went on that
card. When the user opened another project while it ran, the card was
off the screen, so a failure told them nothing (the popover alerts
wherever the user is). And a user who came back mid-publish sees a new
copy of the card, drawn by loadChat: the outcome went to the old copy, so
the error never showed, and the card later read as unused.

The outcome now goes to the card on screen, found by its id. When that
project isn't open, a failure shows as an error toast with the reason.
The card's address field was 13px. On iOS Safari a field under 16px
zooms the page when it's focused, and the page stays zoomed after. Phones
also draw the .puter.site suffix next to it at 16px, so the field and its
suffix didn't match.

It joins the other address fields in the phone rule that sets 16px.
On a phone the card's address field shared its row with the button, and
was so narrow that a longer address was cut off right against its
.puter.site suffix: while publishing, "bakery-orders" showed as
"bakery-ord.puter.site", and an address change to
"sweet-bakery-orders-online" showed as "sweet-t.puter.site", each reading
as a different address.

The card is now a size container. Under 440px wide, a row with an address
field wraps: the field takes the whole row and the button spans the card
under it. Wider cards, and the rows without a field, are unchanged.
The Change address card only sent "Site Address Changed" when its project
was still open at the end. A user who opened another project while the
change ran moved their site, but it wasn't counted. The popover counts it
either way. The card now does too.
The card's inline error ("That address is taken. Try another.") was read
out once when it appeared, but nothing tied it to the field: a screen
reader user who came back to the field heard no error. The same for the
Change address card's warning that links to the old address stop working.

The field now has aria-invalid while it has an error, and
aria-describedby pointing at the error and the warning. Typing a fix
clears aria-invalid along with the error.
The toolbar's and the chat's address change wait for a rename from the
Settings dialog, which marks the project as publishing. The other way
round there was no check: renaming the open project in Settings while its
address was changing from the toolbar or the chat ran both. Each created
a new address and dropped the old one, so one of the two new addresses
was left registered and unused, counting against the account's limit.

The Settings rename now waits for it, with a toast.
The Publish card and publishProject waited while any project's address
was changing. A change keeps running when the user opens another
project, so that project's Publish waited for nothing of its own, and an
unused Change address card there showed "Changing…" for the other
project's change.

Both now ask about the open project only, as the send does.
changePublishedAddress refused while any project's address was changing.
A change keeps running when the user opens another project, and that
project's Change address card is ready (it waits only for its own
project's change), but a click there failed with "Wait for the current
task to finish".

The changes in progress are now kept per project: a project can't start a
second change of its own, and others aren't held by it.
A failed publish left its error on the card's data, and only typing in
the field cleared it. A failed Publish changes, then a publish from the
toolbar that worked, drew the card as Live, which shows no error. The
next change the AI made drew it as Publish changes again, with the old
"Couldn't publish" under it, as if that had failed too.

An error now goes when another publish starts or the card moves to
another state. A redraw in the same state still keeps it.
The field's wrapper turns its border blue when the field has focus, and
the app's global input:focus-visible ring, which text inputs match on any
focus, also drew a 2px box around the bare input inside it: two rings,
one cutting through the address next to its .puter.site suffix.

The wrapper is now the only indicator, with its border and a soft ring
around the whole address (as the delete dialog's field does).
A publish that first saves the Code view's edits, or a turn starting,
disables the card's button without redrawing the card. The browser moves
focus off a button once it is disabled, to the page, so a keyboard user
who pressed Publish with unsaved code edits was left at the top of the
page, and their next Tab started there.

The card now takes focus before its focused button is disabled, as it
already does when a redraw disables it.
The toolbar's and the chat's address change leave the AI a hidden note
naming the old and the new address, since nothing else tells it where the
site is. The Settings dialog's rename moves the site the same way but
left none, so the AI kept the old address, which no longer works, and
gave it to the user ("your site is at ...").

The rename now leaves the same note: in the open conversation, or, for
another project, in its saved one, written with its new address
(savePublishedFields takes the note). Like the other two, it waits for
the open project's turn or restore, so the note never lands in the
middle of a turn, between a tool call and its result.
A publish or a chat address change keeps running when the user opens
another project, and its new address is then saved to the project it was
made for. The hidden note for that project's AI was only added while it
was open, so it was dropped: its AI still went by its last word, "nothing
is public until they click Publish", or the address that no longer
works.

The note now goes into that project's saved conversation, with its new
address (savePublishedFields).
A publish or an address change from a card is saved on its tool result,
so the card reads as used after a reload. It was only saved while the
project was still open, from the open conversation:
- an address change that ended once the user had opened another project
  wasn't saved at all. Back in the project, the Change address card read
  as unused and offered to move the site to the address it already had.
- a publish that ended there wasn't either, and one that went live while
  the project was open but was left during its last save wrote the mark
  into the other project's conversation, where it found nothing.

The mark now goes into the card's own project: the open conversation, or
else its saved file, under the chat file's lock. publishProject tells
the card about a publish that ends in another project (its new away
hook), as it already tells it about one that ends in this one.
A publish or an address change adds a hidden note for the AI at the end
of the conversation. A turn the user stopped between tool calls can
leave a call there with no result, which the next send fills in at the
end (repairDanglingToolUses). With a note in between, that result landed
after the note, splitting the call from its result: a request the model
API rejects, and every send after it, since the history stays that way.
Stop mid-build, then Publish, was enough.

The missing results are now filled in before the note goes in (in the
open conversation, and in a saved one through savePublishedFields).
A copy of a published project starts unpublished, but it inherited the
notes that told the AI the site is public at the original's address, so
the copy's AI gave that address out as the copy's. The copy now gets a
note saying the address is the original's, and the Publish cards lose
the original's publish record, so none reads "Published" in the copy.
Saving the site's address unchanged closed the editor on main, whatever
was running. The new waits for a turn, a restore and an address change
ran before that check, so during a build it was refused with "Finishing
the current task" and the editor stayed open. The unchanged address is
now checked first, as the popover does.

The test's stand-in for the editor's redraw referred to a name the
sandbox doesn't have, so it threw where it was called; it now counts.
OfferPublish should be the last call of a reply, but a model that calls
it before an edit was told the site was up to date with nothing new to
publish. It said so to the user, and once the reply ended the card
offered Publish changes. A change still to run in the reply now counts,
so the AI is told what the card will offer.
An older Publish card crosses out the address its publish went to once
the site has moved. It was #8a8a8a on the card's #fafafa, 3.3:1, under
the 4.5:1 that 13px text needs. It now uses the #6b6b6b of the
"Published" label beside it (5.1:1). Dark mode was already 5:1.
When Change address in the popover is refused during a turn, its
"Finishing the current task" toast had no key or throttle, so each
press of Save or Enter stacked another copy. It now shares the
publish-blocked key and 3s throttle of the other waits.
On a 320px phone the card's field fits about 25 characters at 16px,
and an address the AI suggests can be 30. It was cut mid-letter
against .puter.site, so "neighborhood-bakery-orders" read as
"neighborhood-bakery-order.puter.site", another address. The field now
ends a cut address with an ellipsis; focused, it scrolls as before.
The card arrives during the AI's reply, so its button starts out
waiting. Enter in the address field did nothing, and the dimmed button
gave a keyboard or screen reader user no reason, where the popover shows
"Finishing the current task". Enter now shows the same throttled wait
toast, naming the turn, restore, address change or publish it waits for.
Once the site moves, an older Publish card crosses out the address its
publish went to. The line-through was the only sign, and screen readers
don't read it: the card was heard as "Published x.puter.site", which
sounds live. A visually hidden "(no longer works)" follows it now.
jelveh added 12 commits November 3, 2026 13:48
In the chat beside the preview, a card's link is cut off with an
ellipsis (at about 250px), and a mouse user had no way to see the whole
address. The links and the crossed-out address now carry it as their
title, as the popover's link does.
A chat keeps the system prompt it started with. One from before this
change sends the user to the preview's Publish button and never names
OfferPublish or OfferAddressChange, so in the projects most ready to
publish, the AI had only the tools' descriptions to go on. Such a
prompt now gets the new sentences as a note, the way the required
opening line reaches older chats.
publish_site's description told the AI never to share a public link,
and the notes left after a publish or an address change only said where
the site now is. Asked "what's my link?" after publishing, the AI had
two rules that disagreed, and could hedge or point at the preview. The
description now keeps the link private until the user publishes, and
each note says the AI can give the user the address.
Only the Publish panel in the preview toolbar has the "Remove the
'Made with Puter' badge" box; the Publish button in the chat publishes
without it, keeping the badge. OfferUpgrade told the AI to say "open
Publish", which now reads as either button, and a subscriber asking to
publish without the badge could be offered the chat's button and go
live with it. OfferUpgrade now names the toolbar's Publish, and
OfferPublish's description says its button has no badge option.
OfferPublish and OfferAddressChange made the address they were given the
way a title is made into one: dots became hyphens and it was cut at 30
characters. "Move it to joes-bakery.puter.site" offered
joes-bakery-puter-site, a longer address lost its end, and the AI
presented the result as what the user asked for. An address given with
https:// or .puter.site now drops those, and a valid one is kept whole
(up to 63 characters, as the popover takes). Anything else is still
made into an address.
A publish ships the preview's files. In a project published once and
then opened without a preview, OfferPublish still put a card in the chat
and its Publish changes worked, shipping the whole project folder
(publishedRootDir falls back to it). The toolbar has no Publish button
then, and Settings refuses. OfferPublish now needs the preview, as its
error already said, and a card from before waits, with Enter saying
there is nothing to publish yet.
'Site Address Changed' is new here. The chat's card sent it with source
'chat' and the popover with no source, while the Settings rename didn't
send it at all, so the event fell short of the address changes made.
The popover now sends 'toolbar' and Settings sends 'settings'.
A copy of a published project starts unpublished, so its newest Publish
card is drawn as a Publish form, filled in with the address the AI
suggested for the original. That address is taken by now, so its
Publish could only fail with "That address is taken". The copied cards'
addresses are now cleared, and a publish from the empty field names the
site from the project, as the popover's does.
Mutating the code showed these passed with it broken:
- the address change's refusal while a publish runs, and its refusal
  when the published files are missing (nothing ran either case);
- the publishCardUrl strip, checked only by reading the source: it now
  runs prepareHistoryForAI;
- a busy refusal's words on the card, where any error passed.
The loadChat check also pinned its arguments' order, which a harmless
reorder broke; it now checks each argument.
The Settings rename's test always said no address change was running,
and only read the source for the wait's toast: with the return after
that toast removed, the rename went ahead and the tests still passed.
It now runs the rename while the project's address is changing and
checks that nothing moves.
Two of the publish's rules passed their tests with the code broken:
- it waits while this project's address is moving, checked only by
  reading the source;
- a failure clears the busy state before the surface shows it, which
  every test stubbed out (shown while busy, the card's redraw wiped
  its error).
Both now run the real publishProject.
Its file may not have the published address yet, so the copy reads it
from the live state. Removing that read passed every test.
@jelveh
jelveh merged commit a23b4cb into main Nov 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

publish should be a tool call

1 participant