v4's arrangement, which is the one that reads as an editor: a zone head
holds what acts on THAT zone, so the app grows a bar of its own for what
acts on the app.
* The menu bar. Brand, View and Settings, and whether ComfyUI is still on
the other end. Only menus with something real in them exist -- an empty
File menu is a promise the app does not keep.
* The prompt is written in the CONSTRUCTOR, a window. v4 called this the
Composer; that name is taken here by the element kit, and two things
called Composer in one repo is one thing nobody can search for. It is
the longest thing a person writes and the least often read, and a
permanent third of the centre column is the room the timeline needs.
The host is built at startup and outlives every opening, so a window
that opens twice does not throw away what was typed.
* The timeline replaces it, and Generate moves into its head with what
the run is doing at the far end of the same head. No bar across the
bottom: one that spans the window belongs to no region.
Four faults found on the way, three of them mine and one much older:
* THE OVERLAY ROOT WAS A STACKING CONTEXT. It documents at length that it
must never be one -- and then set `position: fixed`, which makes one on
its own. So the whole z ladder was measured inside that single element:
an open menu at z 500 painted UNDER a button at z 1. Every overlay in
the app was one positive z-index away from being buried, and the only
reason nobody had seen it is that almost nothing claims a z-index. The
root is static and zero-sized now; every layer in it is fixed already.
* Generate's hover ran `filter: brightness()`, which lightens the LABEL
along with the fill under it -- the one thing that had to stay readable
lost contrast in both themes. Only the fill moves now, and which way it
moves depends on the theme: dark's accent carries near-black text so it
goes lighter, light's carries white so it goes darker. Measured: no
gradient stop under 4.1:1, hovered or at rest.
* A fixed-height head could not shrink below the width of everything in
it, and a head now holds a title, three controls and a status. That
min-content became the centre column's, so a 768px window laid out
878px wide and scrolled sideways. It wraps now, as v4's does.
* A CSS patch of mine silently did nothing because its anchor text had
moved and I had left the assertion off that one replacement. Every
patch asserts now.
/queue answers with queue_running AND queue_pending, and only the first was
looked at. A job sits in the second from the moment /prompt returns until the
worker picks it up -- always briefly, and for as long as it takes whenever
something is running ahead of it, which on a shared box is the ordinary case. A
reload in that window found nothing, handed Generate back, and let the same job
be queued twice: the first copy then ran unnamed, its messages failing the id
check, burning GPU time while the UI narrated the second.
Every fixture in client.test.js was cleaner than the real payload -- none of
them carried a queue_pending key at all, though ComfyUI always sends both. That
is the third time a stand-in tidier than the real thing hid what it stood in
for, so the fixtures now carry both halves, including the malformed entries a
real answer can contain.
Also renamed, because the function no longer answers the question its name
asked: queuedFor returns which half it found the job in, and a job waiting its
turn is adopted as QUEUED. Saying "working" over a run that has not started is
the same small lie as saying "cancelled" over one that has not stopped.
/interrupt only consults the RUNNING half of ComfyUI's queue. A job waiting
behind another one is not there, so cancelling it returned 200, logged "not
currently running, skipping interrupt", and let the generation run in full when
its turn came -- the user asked for it to stop, the UI showed it stopping, and
the GPU spent the time anyway. Two tabs against one ComfyUI is all it takes, and
one tab per generation is now the normal arrangement.
ComfyUI has a route for exactly this: /api/jobs/<id>/cancel interrupts a running
job and dequeues a pending one, atomically, and answers {"cancelled": false} for
an id that already finished. An older ComfyUI without it falls back to the
interrupt, which is right for the case people hit most.
Reading that answer matters: a dequeued job never runs, so no
execution_interrupted is ever sent, and the run would have sat at "Queued"
forever after a cancel that worked. A run that IS executing is still left to the
socket to declare stopped -- an interrupt is a request, and claiming it early
shows "cancelled" over a model still finishing its step.
The one test touching cancel drove execution_start first, so it only ever
exercised the running case -- which is the half that already worked.
The fix a commit ago buffered messages only while QUEUED, which is the window a
page creates for ITS OWN run. Reattach opens a second window nobody covered: the
socket is live, the phase is idle, and the queue has not yet said which run
belongs here. Anything arriving in that gap fell straight through and was
dropped -- and a run that ended inside it had already left `queue_running` by
the time it was asked about, so nothing found it either. The GPU produced a
picture, the app said Ready, and the result was gone.
Held whenever the id is unknown, not only while queueing. Then two questions on
load rather than one: what is still running, and -- if nothing is -- which of
the runs this page actually SAW on its own socket was ours, answered from
ComfyUI's history by the client id it recorded. Only ids seen during this page
load are asked about, so a result from an hour ago cannot be resurrected as
though it had just arrived.
Every reattach test called adopt() before sending anything, so none of them was
ever in the window that adopt() exists to close.
Three faults, all living in the length of the /prompt round trip -- a real
network delay during which the socket is already live and everything ComfyUI
broadcasts arrives.
* Cancel in that window POSTed `{"prompt_id": null}`, and ComfyUI reads a
missing prompt_id as a GLOBAL interrupt: it stops whatever any client is
running. On a shared box that is someone else's generation, and the clicking
user's own request may not even be queued yet. The cancel is remembered and
sent once the run has a name.
* `mine()` treated "no id yet" as "everything is mine", so a stranger's
execution_success and images flipped this run to Done -- showing another
client's picture, for a run that had not started. Messages arriving before the
id are now held and sorted out once it is known; dropping them instead would
lose a run short enough to finish inside the round trip. The buffer is bounded,
because on a shared server it fills with other people's traffic.
* The socket only opened at Generate, so the reattach the client id exists for
never happened: after a reload the app sat at Ready while its own generation
ran on, and pressing Generate queued a SECOND job beside it. It listens at
load and asks ComfyUI's own queue which run belongs to this client -- asked
rather than remembered, because a remembered id reattaches to a run that may
have finished while the page was gone.
Every existing test awaited start() before sending a message, so none of them
ever entered this window. The new ones hold the /prompt response open, which is
what a round trip actually is.
ComfyUI's own queue and its own socket, not a second one alongside them. A
FunPack queue would be a second thing tracking what is running, and the one the
user believes is whichever spoke last -- so this speaks to /prompt, /interrupt
and /ws as any ComfyUI client does and holds only a view of the server's state.
The graph is asked for, never assembled here, for the same reason.
The client id is remembered: ComfyUI addresses progress to the client that
queued the run, so a reload with a fresh id hears nothing about the generation
already in flight -- the run continues, the GPU time is spent, and the app says
Ready.
Faults found on the way, each in code that already had passing tests:
* `[hidden]` was inert across the whole kit. The UA rule is element-level, so
`.cx-btn { display: inline-flex }` beats it -- Cancel was marked hidden, sat
there, and nothing reported anything. jsdom cannot see it: the attribute was
always correct. One reset rule, and a browser test over four element kinds.
* A panel passed no height to its body and a split pane passed none to its
panes, so the viewer asked for 100% of `auto` and collapsed to a single line
inside a panel that was the right size around it.
* subscribe() delivered the first state outside the guard every later delivery
gets, so one bad listener took down whoever was wiring the UI up -- at
startup, before anything existed to report it.
* A whole tests directory was never run: the test script globbed
composer/tests only. `node --test` discovers them itself now, so there is no
list to forget.
* `empty` was not in the content rule's key list, so three elements were never
asked to prove their empty-state text comes back as text rather than markup.
The dev server now mounts the REAL routes -- register() already took a route
table -- instead of reimplementing them over http.server. It answered POST
/api/pipeline with 501, so the edit-and-queue path could not be looked at, which
is the one thing the dev server exists for.