The box around a text layer inside the playground card stopped mid-word. The
layer is 260px wide and paints 313, because its parent carries `scale(1.2)`,
and the chrome read only the element's OWN transform. The top-left looked
right, since the corners are anchored to the real bounding rect, so only the
right and bottom edges fell short, by exactly 1/1.2.
The same read decides whether to draw the box rotated at all, so an element
whose parent is rotated got an upright box over a rotated one.
The transform is now accumulated from the element up to the composition root.
Only the linear part matters: each transform's origin contributes translation,
and translation is already discarded by matching the corners to the element's
bounding rect, so composing the matrices is enough and no per-ancestor origin
has to be unpicked. The walk stops inside the composition document, because the
canvas zoom lives on the iframe in Studio's own document and is applied
separately.
The fake DOMMatrix the geometry tests use gained the `multiply` it now needs.
Resizing `#card` in the playground failed with "animation not found", and the
element could not be saved again at all.
The tween resolved for the resize's group is, for a scale-driven element, the
one carrying `scale`. When it is an instant hold the code handed it straight to
the size commit, which wrote `width` and `height` into it. One tween now spanned
two property groups, so the parser classified it as neither — it lost its group
suffix, and its id with it. Every later edit looked for a scale tween and a size
tween, found a tween with no group at all, and had nothing it could address.
Size goes to a size hold of its own now; the scale hold is left alone. The
damage is repairable where it has already happened: splitting the mixed tween
into property groups gives back a `scale` tween and a `size` tween.
A rich-text patch adds spans, and it left them without their stable ids. The
next preview request minted them and wrote the file a second time — after
Studio had recorded the edit in its history. The recorded "after" no longer
matched the bytes on disk, so the content check refused and undo reported the
file as changed outside Studio. Every colour applied to a run of characters
and every text layer added poisoned the history the same way. The patch now
mints those ids itself, with the same function the later pass would have used,
in the bytes it writes and hands back. The clip split already did this for its
clone and its comment names this exact failure.
Undo and redo also move out of the header and into the timeline toolbar, left
of the selection tool. They act on the timeline and the canvas; the header is
for the project. They read the shell optionally, so the standalone timeline the
player package exports still mounts without a project.
Every colour chosen in the preview was dropped on the way out, silently, for
any element whose text contains a line break.
Two gates disagreed. The press that opens an edit asks whether the element's
own markup can be rewritten. The commit asked the design panel's question
instead: does this element have text fields. It has none when its text
contains a `<br>` — a `<span>` holding one is not a leaf, so nothing inside
counts as a field and the element reports no editable text at all. So the
caret opened, the toolbar worked, the preview showed the styling, and the
commit returned without a request, a file change or a word about it.
The commit now asks the same question the press did. The panel's own text
commits still use the panel's rule, which is theirs.
Also stops the writer's own `data-hf-id` counting as a layer identity: it is
stamped onto every element on the way to disk, so once a file had been saved
the wrapper this rebuild adds inside a flex container looked like a layer
again and went back to hiding the real ones underneath it.
Choosing one colour for three characters painted a different shade onto each
character of the whole line.
Two faults, both in putting the selection back after the rebuild. The offset
walk counted a line break as a character everywhere except the one place that
maps an offset back to a DOM position, so in an element containing a `<br>`
the restored selection sat one character further along for every break before
it. And a native colour picker reports every sample while the pointer moves
inside it, so one choice of colour arrives as a stream of them — each restyling
a selection that had walked on since the last.
The wrapper this rebuild adds inside a flex container carries nothing, and
reading it as the layer it sits around hid the real one below it, so a second
edit of the same element threw away every identity the first one had kept. An
element with nothing on it is no longer taken as an identity.
Three faults that met in one place: an element whose children are text layers.
Removing a layer, or adding one, could never save. The per-child operations
can only address children that already exist, so they refuse any change in how
many there are — which is every add and every delete. The panel offered both
buttons and both ended in "Couldn't save this text structure change". A
structure change now writes the structure, through the same operation an
inline style edit already uses.
Styling a run of characters threw away the identity of every layer in the
element. The rebuild emitted fresh, bare spans, so the attribute the panel
tracks a layer by went with them, and afterwards nothing the panel offered
could match a layer to its source. Runs now carry the child they came from and
the rebuild puts its identity back, without duplicating it onto a run that was
split off.
Saving a styled run deleted its neighbours' typography. The allowlist was
paint-only, on the reasoning that text edits should not move or resize things
— but the design panel writes exactly those typography properties onto exactly
those spans, so the rule did not stop text changing layout, it deleted the
layout the user had set. Colouring one word dropped a sibling's font size.
A scale resize measured its drop-point correction while the gesture's own
translation was still applied, but the position commit adds that correction
onto the element's PRE-gesture position, which it reads from the gesture's
base attributes. The two disagreed by the whole drag distance, so the commit
persisted a position a drag-length from where the element was dropped: it
held the drop point for one frame and then slid off.
Move the element back to that base before measuring, so the residual and the
commit share one origin. For an element whose position is a static hold that
usually means no correction at all, which is the right answer: scaling about
the centre already leaves it on the drop point.
An element whose position is animated left the drop point anyway. The
finalize step wrote its correction as a static position hold, and the
element's position tween rendered its own value a frame later and won.
Before that it stood down entirely on such elements, on the grounds that
a keyframed path has no single anchor to preserve, which had the same
visible result: the element moved.
It has an anchor, the frame the user is looking at. The correction now
goes into that tween at the playhead, through commitGsapPositionFromDrag,
which is the same commit a drag on the same element already uses. Static
holds keep the existing path.
This is the difference the debug log showed between an element carrying
position:to, which moved after release, and one carrying position:set,
which did not.
The finalize step measures where the committed scale put the box and
shifts the position hold by the difference. Whether the commit had
actually rendered when it measured was luck: on a first resize the
timeline had not re-seeked, so it measured the element at its natural
size still sitting on the drop point, saw no residual, and skipped the
correction. The scale then landed, GSAP rendered it about the element's
centre, and the element jumped by the whole drag distance. Elements
resized before got a correction only because their previous scale made
the residual non-zero by accident.
The committed scale is now applied to the live element before measuring,
so the measurement means what its comment says either way, and a skipped
correction is logged rather than silent.
Confirmed against a real session: a first resize of a 630px chip now
reports residual -109.93 and lands on the drop point, where it previously
logged no scale-finalize at all.
Resizing an element whose size is driven by a scale animation committed a
scale computed against a hardcoded 200px, because the only original size
recorded was the element's INLINE width and a composition sizes its
elements from the stylesheet. A 630px chip dropped at 1260px wide committed
a scale of 6.3 instead of 2, so it landed over three times the size it was
dropped at, and the next drag compounded it because that wrong scale then
counted as the element's live one.
The draft now records the box it measured, once, before it writes a width,
and the intercept reads that. The inline attributes keep their own job of
restoring an inline style.
Three faults found by asking what an actual composition holds that the
happy path does not.
A newline in an element's text became a visible line break. Compositions
are written across lines, so most multi-line elements carry real newlines
that are only source formatting, and the marker standing in for <br> was
a newline. It is a NUL now, which the HTML parser cannot produce, so
source formatting stays text.
A selection dragged past the element's edge rebuilt the ancestor the two
ends met at, flattening every sibling element inside it into text. The
element is resolved from where the selection starts, and has to hold both
ends before anything is rewritten.
A selection boundary landing inside an emoji split the surrogate pair
across two spans, leaving half a character in each. The boundaries widen
to whole characters first.
Also covers -webkit-box, which is how line clamping is written and boxes
its children the same way flex does.
In a flex or grid container every child is an item to be laid out. Text
that was a single anonymous item became several boxes the moment one word
inside it was styled, so colouring a word in a centred chip broke the
centring and rewrapped the whole line into columns.
The runs go inside one wrapper span when the element lays out its
children, which keeps it a single item with inline text inside. Verified
against the chip in the preview: same width and height before and after,
one child, text unchanged.
Double-clicking inside an open edit took the whole text, because a
handler here claimed the gesture to do exactly that. It cost the word
selection every text field gives you and gained nothing, since triple
click already takes the lot.
Deleting the handler is the fix: the browser takes the word on two and
the whole text on three, and nothing in the preview intercepts either.
Three faults found by driving the real editor rather than the tests.
It was mounted with the selection chrome, which does not render for every
selection, so for most elements the toolbar never appeared at all. It now
mounts with the canvas overlay, which renders whenever the canvas does.
Its presses reached the canvas underneath, which reads a press as a click
on the composition: pressing a control deselected the element and
committed the edit out from under the button. They stop at the toolbar.
The invisible colour input carries a user-agent minimum width, so pinning
it with inset-0 alone let it spill across its neighbours and hovering bold
opened the colour picker. Its size is pinned instead.
Frees the lines this needed in the canvas overlay, which is at its limit,
by extracting the child outlines it draws.
Editing text in place could change what the text said but not how any
part of it looked, because Studio had no way to write markup into a
composition: the patch vocabulary was inline-style, attribute,
html-attribute and text-content, and text-content assigns textContent.
The text-field model escaped markup on the way out and refused a change
in child structure, so a span could not reach the file by any route.
Adds a rich-text operation that can, guarded by one sanitiser in
packages/core called on both ends of the trip: in the browser so the
preview shows what will be saved, and on the server because that is
where the file is written. Tags and style properties are a small
allowlist; an unexpected tag loses its formatting, not its words.
The editing surface drops plaintext-only, with the guard it was buying
rebuilt as paste-as-text plus that sanitiser. Styling is applied by
reading the element into a flat list of runs and rebuilding it, rather
than wrapping a range: replacing a colour, removing one, and styling
across an existing run all stop being special cases, and the markup
cannot grow with repeated edits.
The inline gate no longer rejects an element with children, which would
have locked every element out of the editor that had ever styled it.
The canvas overlay goes pointer-events-none while a text edit is open so
the caret below can be reached, but pointer-events: none on a parent does
not disable a child that sets auto. The selection box does exactly that,
and it covers the element being typed into, so it swallowed every press:
the caret could only be placed once, when the edit opened, and dragging
across characters selected nothing.
The chrome now reads the editing session and stands down with the overlay,
keeping only its outline so the edited element is still marked.
Opening at a fixed end made this feel like a dialog that happens to
contain text. The caret now lands between the two letters that were
pressed, which means mapping the point out of Studio's coordinates and
into the scaled composition's before asking the document what is there.
A point that resolves outside the element, which a rotated glyph or a gap
between words can do, falls back to the end rather than putting the caret
in someone else's text.
Double-clicking again once the edit is open takes the whole text. The
browser would take a single word, but here the element is the field, and
replacing all of it is the common intent once the caret is already in.
Double-clicking selected the whole word, so the next keystroke would have
silently destroyed it. That is a bad thing to do to someone who
double-clicked to fix a typo.
The caret now sits after the last character instead, which is where a
person who wants to keep typing expects to be, and it goes into the text
node rather than collapsing at the element boundary so it is the same
position the user sees at the end of the word. Everything else stays
available from there: click anywhere in the text to move it, drag to
select a phrase, Cmd+A to take the lot.
Three things were wrong with editing text in the preview, and the first
is the one that made it feel broken.
Letters were being eaten. Every shortcut guard asked whether the target
was `[contenteditable='true']`, which does not match the
`plaintext-only` an inline edit uses, so the playback shortcuts claimed
them: `a` seeked to the in-point, `e` to the out-point, preventDefault
and all, and the character never reached the text. One shared
isTypingTarget now answers that question by asking isContentEditable,
which is true for every editable value and for children of one.
A double press could never open a fresh element. It read the hover and
the selection, which are both React state and are still whatever they
were before the first press landed. The element is now hit-tested at
press time through the canvas' own helper, which also lifts the
pointer-events suppression Studio puts on the composition.
There was nothing to tell you the caret was in the text rather than the
element merely being selected, so the element now carries an accent
outline for the length of the edit, drawn in the composition beside the
caret rather than in Studio's overlay above it.
Enter also opens the selected element's text, which is what every design
tool does and does not depend on the gesture machinery at all.
Track picking was as bad: the target was a bare number sitting between
the name and the eye, reading as a clip count. The name is the target
now, and picking a track opens the composer scoped to it, so asking the
agent about a track is one gesture rather than a hunt for a second one.
Verified in a real browser, which found three things the unit tests could
not. The gesture had to be rebuilt around each of them.
There is no dblclick to listen for. The selection box takes pointer
capture on the first press and prevents its default, so the browser never
pairs the two presses: no dblclick is dispatched anywhere, the
compatibility mousedown for the second press never fires, and detail
stays 1. The pair is now recognised from the time and place of successive
presses, on the box itself, which is the element actually being pressed.
Focus and select-all were being undone. Both ran while the opening press
was still in flight, and the canvas overlay takes focus on pointer-down
while the click that follows puts a caret in the element and collapses
the selection. They now run on the next frame, once all of that is over,
and the overlay stops taking focus back while an edit is open.
Verified end to end in Chrome on a static composition: a double press
opens the element with the whole text selected, the overlay stands aside,
editing it produces no markup, Enter closes it, and the new text is on
disk. The canvas wiring moved to its own hook to stay inside the studio
line cap.
Changing a word meant selecting the element, crossing to the right panel,
finding the text field, editing it there, and looking back at the canvas
to see the result: the edit in one place and the feedback in another, for
the most common change anyone makes.
Double-click now puts a caret in the element itself. The text is edited
in the composition, so what is typed is what it will look like: the same
font, size, colour, wrapping and alignment, with nothing to reproduce and
so nothing to get subtly wrong. Enter commits, Escape restores, clicking
away commits, and Shift+Enter still breaks a line.
Committing goes through the text path the panel already uses, so
persistence, failure recovery and resync are unchanged. The element is
contenteditable=plaintext-only, so typing and pasting cannot turn a text
edit into a structural one.
The overlay stands aside for the length of the session, which is both the
only way the caret underneath is reachable and what stops selection, drag
and marquee firing mid-edit. Elements with several text fields keep the
panel, which edits them a field at a time; editing the whole element
would flatten its children into one string.
Double-clicking anywhere in the viewport while zoomed in snapped back to
the default zoom, on a document-level capture listener. That takes the
gesture away from the thing it should mean on a composition, which is
"edit this", and it fired on any double-click that happened to land over
the preview.
Zoom still has its own controls; nothing else changes.
The element run and the timeline range run built the same POST, handled
the same failure and did the same thing with the job. Extracting the
range run into its own hook made that copy visible to the duplication
gate, so both now go through one function and differ only in the body
they pass.
Also splits the skeleton placement out of its memo, and tells agents in
the skill to declare before they start writing rather than after: a
declaration arriving with the final message has nothing left to cover,
because the run ends and the real clips replace it in the same breath.
With a track selected, the popover lists only that track's clips, says
which track it is scoped to, and the prompt tells the agent two things it
could not know before: which track the request is about, and that
anything it adds belongs there. That is what makes "add a clip here"
answerable instead of a guess.
The prompt also stops telling the agent the wrong track. It printed the
display lane labelled "track", and Studio packs authored tracks onto
contiguous lanes, so on any composition with gaps (authored 0, 4, 9 draws
on lanes 0, 1, 2) an agent sent to "track 1" would edit the element the
file calls track 4. Both timeline prompts now name the authored
data-track-index.
The prompt builders move to their own module and the range run to its own
hook, because timelineEditing.ts and useAskAgentModal.ts were both close
enough to the 600 line cap that this would have broken them.
A range is made on the ruler and so spans every track by construction.
This is the other axis: click a track and the timeline is scoped to it,
click it again or press Escape and it is not.
The row itself cannot be the button, since it already holds the
visibility eye and the lane caret, so the track's own name is the button.
That also gives it a keyboard path and an accessible name, which a click
handler on the row would not have. In the narrow gutter, and on a
keyframed row whose label is already the clip's name, it shows the track
number instead.
The selection lives in a small module store rather than a context: its
two readers are in different subtrees, and the only component wrapping
both is Timeline.tsx, which is at its 600 line cap.
A declared clip now appears on the timeline as a skeleton for as long as
the run is live, at the time and on the track the agent named. Nothing is
inferred: a run that declared nothing draws nothing, however busy it is,
and a settled run draws nothing at all.
A track that has no clips yet cannot have a row, so those are drawn in
the pad below the last one, outlined rather than filled, and split
between them when an agent declares more than one. That pad is already
inside the canvas height, so this needed no change to the height
calculation, and Timeline.tsx stays at 599 of its 600 permitted lines.
The layer takes no pointer events anywhere. A skeleton is a promise about
a clip, not a clip, and a click has to reach whatever is underneath.
The canvas reads the runs from the DomEdit context itself rather than
taking them as a prop. That keeps the line cap safe, and it keeps
standalone player mounts, which have no project and no agent, rendering
exactly as they do now.
An agent only ever knows data-track-index. Studio packs those onto
contiguous display lanes, so on any composition with gaps the number the
agent writes is not the row the user sees: authored 0, 4, 9 draws on
lanes 0, 1, 2.
This is the inverse of authoredTrackForLane and sits beside it so the two
stay readable against each other, with a round-trip test holding them to
agreeing. It is scoped by source file for the same reason the forward
direction is: an expanded sub-composition's rows carry authored numbers
from their own file, and matching one would draw on a row belonging to a
different composition.
A track with no clips yet reports provisional rather than snapping to the
nearest lane. An agent adding a whole new track is the most interesting
thing it can say, and it has to be drawable before the clips that would
give it a lane exist. The result is a discriminated shape, not a number
with a sentinel, because lane 0 is a real answer.
A run stopped on a question now shows what it is being asked and the
agent's own options, one button each; clicking one sends that option's id
and the run carries on. The element says so too, in amber and still,
where every working state moves: a run that has stopped must not look
like one getting on with it. Being waited on leads the tray's headline
and outranks working in the canvas badge, because it is the only state
the reader can do something about.
Three files had to come apart first, all of them over the editor's
600-line cap and so uncommittable as they stood:
- the canvas connector moves out of InlineAgentComposer into its own
file, and the composer's geometry joins the layout module that already
owned the rest of it (813 -> 551)
- the queue's mutations move out of useAskAgentModal into useAgentQueue,
which is where answering belongs (653 -> 598)
- useDomEditSession listed all 34 agent fields twice, once to
destructure and once to return; it spreads them instead (604 -> 540)
Polling also treated a parked run as idle, so an answer took up to eight
seconds to look like it had landed.
The OpenAI wordmark stood in for Codex; this is the icon Codex actually ships,
white plate and gradient mark, so it reads the same in the picker as it does in
the dock.
Both belong to the same element, so placed independently they landed on top of
each other. One pass now places both: the composer takes the side it prefers,
the answer takes the other, and where only one side has room the answer stacks
beyond the composer rather than across it. The bubble's tail keeps tracking the
element either way.
Not every instruction is an edit. "Why is this off-centre?" comes back as an
answer, and an answer that only lands in a run list is an answer nobody reads.
A finished run with something to say now surfaces as a bubble on the canvas,
its tail tracking the element's centre so a bubble pushed along by the canvas
edge still points back at what it answered. It can be expanded, dismissed for
good, or carried straight into a follow-up.
Also makes the element name in a run row read as the control it is — a target
glyph, a resting fill and a pointer — instead of looking like the caption
beside it.
Effort levels come from the same catalog the models do — a model advertises
what it accepts, and Studio offers exactly that. Anthropic reasons on a token
budget rather than a level, so a Claude Code run carries no effort at all
instead of a knob that does nothing.
The chip sits to the right of the model, because it is the same decision: how
much thinking to buy. A run takes the lowest level the model offers unless the
user picks otherwise, the choice is remembered per harness, and each run in the
tray shows the effort it actually used.
Also drops the OS tooltips from the harness and model chips — they covered the
element they described — and portals the composer and ask handle out of the
canvas overlay. That overlay is a z-10 stacking context whose sibling motion
path sits at z-40, so nothing inside it could ever come out on top, however
high its own z-index was; the keyframe diamonds kept drawing through the pill.
Three modules were each re-implementing the same four concerns — reaching a
Storage that can throw, parsing JSON that may be anything, deciding what a bad
value means, and writing without letting a quota error escape — with their own
try/catch and their own answer to each. createJsonStore owns the first, second
and fourth; a caller supplies only its key, its fallback, and how to read its
own shape.
UI preferences, agent drafts and render settings now sit on it. Validation
stays with each caller, since only it knows which fields are meaningful.
The agent edits files, the watcher reloads the preview, and the reload took the
composer's React state with it — an instruction being written while an earlier
run landed disappeared mid-sentence. Drafts are now stored per element, so a
reload (or a trip to another element and back) returns the words, and
submitting clears them.
The composer and the ask handle sat at z-20 inside the canvas overlay, but the
overlay's own decorations climb to z-40 — so the motion path and its keyframe
diamonds drew straight through the pill. Both now sit above them, through one
named constant rather than a number picked per component.
The composer, run tray, both context menus and the ask handle each carried
their own copy of the same surface (layered shadow, hairline ring, blur) and
row treatment, which is how the menus and the tray drifted apart in the first
place. They now share components/ui/floatingSurface.
Also reworks the ask affordance: a mark at rest that widens into "Ask ⌘K" on
hover, in CSS, instead of the native OS tooltip that covered the element it
pointed at.
Selection chrome on a 3D-transformed element: rect + angle cannot describe a
keystoned projection, so the overlay drew a rotated box beside the element and
the crop frame landed somewhere else entirely. A projection that is not a
rotated rectangle now reports its axis-aligned bounds and says so, and crop —
which only exists in the element's own plane — stands down.
Auto-opening the composer on every selection put a panel on the canvas whether
or not the user wanted to ask for anything. The entry point is now a small
harness mark pinned to the selection: it sits where the eye already is, opens
the composer on click, and names the ⌘K shortcut in its tooltip — discoverable
without taking the canvas.
Also gives the run tray an inline z-index. It carried a utility class that lost
to the timeline ruler and the panels, so the ruler drew straight through the
tray's rows.
Selecting an element now opens the composer on it — the selection was already
the ask, and waiting for a second gesture on the thing just clicked was a step
for nothing. A multi-selection carries through: the prompt names every
co-selected element and widens its guardrail to "change every one of them", and
the field says how many it is talking about.
The harness and model lists are capped and scroll on their own instead of
growing the composer, and they declare they own their wheel so the canvas
underneath stays put.
Adds usage telemetry for the whole surface — composer opened, run submitted,
harness and model chosen, custom harness added — carrying the harness, model
and element count, never the instruction text.
The model was buried at the bottom of the harness menu, where it read as a
detail of the harness rather than the other half of "who will do this". It is
now its own chip to the right of the harness, with its own list.
Harnesses are no longer limited to the four Studio ships presets for: the
picker can register one, with the same fields a preset carries — command, args
before the stdin prompt, its own mark, and the flag it takes a model on. They
live in <project>/.hyperframes/agents.json, so the choice travels with the work
instead of being an environment variable one machine knows about.
Harness selection is by id now rather than kind, since two custom harnesses can
share the "custom" kind.
Models come from the models.dev catalog — the same community index other agent
tools read — filtered to the provider each harness authenticates against and to
models that can actually call tools. Nothing about a model is hardcoded here: a
model released next week shows up without a change to Studio, and the catalog's
own pricing decides the default, which is the cheapest model that can drive a
run rather than whatever the harness would reach for.
The composer's picker gained a model list (cheapest first, with input price),
the choice is remembered per harness in the studio preferences, and each run in
the tray shows the model it actually used.
Payloads that cross a boundary — request bodies, the run log, the catalog — are
now zod schemas with inferred types instead of hand-written `unknown` readers
and casts.
Every agent affordance now names the harness that will actually run — the
context menus, the inspector footer and the empty state read one effective
value, so a picked Codex can never sit behind a menu still offering Claude
Code. ⌘K opens the composer on the current selection.
The marks are the vendors' own: OpenClaw's pixel lobster and the Nous Research
portrait Hermes ships, alongside Claude's and OpenAI's.
The run tray moved off the canvas — it floats over the shell, drags anywhere,
and remembers where it was parked. Harness pick, tray position and collapsed
state all live in the studio preferences file rather than being re-chosen every
session.
Context menu rows were rebuilt as inset items so a hover fill covers the whole
row instead of leaving gutters at the edges.
The dot repeated what the row already said. A run in flight now carries a
progress rail instead — sweeping while the agent works, still while it waits —
and a waiting run says where it sits ("Next up", "#3 in line") rather than
just "Queued".
History written before runs recorded their harness fell back to the neutral
mark; the harness is now recovered from the logged label, so past Claude Code
runs stop rendering as an anonymous spark.
The composer header is now a picker: every harness Studio knows is listed with
whether it is installed, so a queue can mix Claude Code, Codex, Hermes and
OpenClaw instead of taking whatever was auto-detected. Each carries its own
vendor mark, and the tray stacks one mark per harness in play (only when more
than one is).
Each run also carries its session id and the coordinates of the element it
edited: clicking the element name seeks back to that moment and re-selects it.
Fixes a wheel over the run list panning the canvas underneath instead of
scrolling the list — floating overlays now declare that they own their own
scrolling, which the preview checks before it pans or zooms.
The queue was a promise chain, so a waiting run could only be watched. It is
now an explicit list: queued runs can be moved sooner or later, dropped before
they start, and a run already going can be stopped (the tray reveals those
controls on hover).
Every settled run is appended to <project>/.hyperframes/agent-runs.jsonl —
when it ran, which element, the instruction, how long it took and what the
agent reported back. Studio reads that log when a project is first listed, so
the history survives a server restart instead of living only in memory.
The header repeated the selected element, which the field's own placeholder
already says. It now names the agent that will run the instruction (Claude
Code, Codex, …), falling back to the element when no harness is installed.
Runs become server-owned jobs instead of one blocking request. Submitting
clears the field immediately, so several instructions can be fired at
different elements in a row; per project they execute one at a time, since two
agents rewriting the same composition would clobber each other.
A tray on the canvas lists every run with what the agent is doing right now
(Claude Code runs in stream-json so each tool call surfaces as it happens),
its target, elapsed time and final message. The list is read from the server,
so a page reload lands back on the work still in flight.
Adds Hermes and OpenClaw presets alongside Claude Code and Codex, each with
its own mark, and HYPERFRAMES_AGENT_ICON to point any other harness at a real
icon file.
The composer can cover the element it edits, so its header is now a drag
handle. The header also shows which harness is running: the server reports a
kind alongside the label (claude / codex, sniffed from a custom
HYPERFRAMES_AGENT_CMD and defaulting to claude when inconclusive) so the mark
is never blank.