Skip to content

Add a version manager for linking workspaces and moving versions - #63

Merged
AlexKempen merged 98 commits into
certfrom
claude/onshape-version-manager-tan3xh
Oct 21, 2026
Merged

AlexKempen merged 98 commits into
certfrom
claude/onshape-version-manager-tan3xh

Conversation

@AlexKempen

Copy link
Copy Markdown
Collaborator

Links Onshape workspaces to each other and moves external references
between them: a pull updates this workspace's references onto the newest
versions of what it consumes, and a push cuts a version here and moves
the references of the workspaces that consume it onto it.

Rebuilt from the implementation this repo carried before the rewrite
(5fb1ff5, Jan 2024), with three changes:

  • Links live in the app's own D1 database, one row per edge, rather
    than in a Firestore collection holding both ends separately, where a
    link could half-exist when only one of the two writes landed.
  • A push updates the workspaces linked directly downstream and stops.
    The old one walked the whole chain, cutting a version in every
    document along it; that is now the opt-in recursive push, which
    needs those versions for the next hop to reference.
  • Both run in a Workflow, so a chain of Onshape writes caught by a
    rate limit backs off and resumes rather than leaving a version cut
    and half the references moved.

The page is a tab beside the libraries, shown only when the panel was
opened in a workspace, since that is what it acts on.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk

claude and others added 30 commits September 18, 2026 18:08
Links Onshape workspaces to each other and moves external references
between them: a pull updates this workspace's references onto the newest
versions of what it consumes, and a push cuts a version here and moves
the references of the workspaces that consume it onto it.

Rebuilt from the implementation this repo carried before the rewrite
(5fb1ff5, Jan 2024), with three changes:

  - Links live in the app's own D1 database, one row per edge, rather
    than in a Firestore collection holding both ends separately, where a
    link could half-exist when only one of the two writes landed.
  - A push updates the workspaces linked directly downstream and stops.
    The old one walked the whole chain, cutting a version in every
    document along it; that is now the opt-in recursive push, which
    needs those versions for the next hop to reference.
  - Both run in a Workflow, so a chain of Onshape writes caught by a
    rate limit backs off and resumes rather than leaving a version cut
    and half the references moved.

The page is a tab beside the libraries, shown only when the panel was
opened in a workspace, since that is what it acts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Each linked workspace gets its own button: an upstream row pulls that
workspace's latest version, a downstream row pushes to that workspace
alone. Both narrow what the run touches rather than changing what it
does, so the whole-graph buttons above them are unchanged.

A row's pull runs on the click, having nothing to ask for. A row's push
opens the version form first — a version cannot be cut without a name —
with the recursive option left out, since a push aimed at one workspace
has nowhere further to go.

The scope each route takes is now a tagged shape instead of a boolean
and a string, so "one linked workspace" is sayable in both directions
and an invalid combination is not. The named workspace is checked
against the links before anything runs.

Also: the add-link field is a card with the field and an Add button on
one row, as the app this came from had it, and the recursive option says
"Recursive push".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Every button runs its push or pull straight away. A push with no name
takes the local date and time, which is what somebody wanting their
change visible downstream is after; the caret beside each button opens
the form that names one, seeded with that same default so editing starts
from something.

The page-level buttons are gone. Each group's action lives in its title —
pull in Parents, push in Children — and each row carries its own, so
there is nothing left that acts on "everything" from outside a group.
Recursive is a checkbox in the Children title, beside the push it
governs, rather than a question the form asks after the fact.

Parents are the workspaces this one references and pulls from; children
are the ones that reference it and get pushed to. Note this is the
opposite of an assembly tree's sense, where the assembly is the parent of
its parts — here the document a change starts in is the parent. The
wording runs all the way down: the link directions, the pull scope, the
graph walk, and the stored edge's two ends.

The field that adds a link now sits under the list it adds to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A quick push no longer invents a name from the clock. It takes the one
Onshape's own dialog offers — V<n> after the highest the document already
has — worked out per document, so a recursive push numbers each from its
own history rather than carrying the first one's number. The name is the
server's to pick, since reading a document's versions is an Onshape call;
the form leaves the field empty and says so.

The page is now two accordion sections built like the home page's, each
with its direction's arrow in the title and a Quick push or Quick pull
button beside the control, where it is reachable with the section shut.
Everything else moved into menus: the section's own carries the recursive
push, naming a version, and updating every reference; each row's carries
the same for that one link, plus opening the document and removing the
link. The recursive checkbox is gone with them. Rows lead with a file
icon.

There is no recursive pull, and the row menu says why: a pull writes only
to this workspace, so going further would mean versioning a parent's own
parents, which is a push and theirs to make.

Both scopes are enums now rather than bare strings, and the single-child
push carries whether it keeps going, which is what a row's recursive push
needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Quick push moves out of the button labels and into a checkbox on the
page's own navbar row, where the search belongs on a library page.
Checked, a push runs on the click; unchecked, it asks for a name first.
A pull has nothing to name, so it always runs, and the tooltip says so.

Every link now carries its own button beside the section's, so pushing to
one child is a click rather than a menu. The menus keep what a button
cannot say: the recursive push, and updating every reference.

A run reports itself where it started. The button that began it holds a
spinner and says what it is doing on hover, in place of the banner over
the page; what the run did still arrives as a toast at the end, since a
finished count is not something a spinner can hold.

The naming form opens on the name the push would have used, read from
Onshape through a new route rather than guessed at — offering a number
Onshape has already handed out would be worse than the wait.

Also: button icons sit after their labels, the accordion chevrons are at
the end of their rows rather than against the titles, and a link the
caller has no permission on says so in red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Two empty sections say neither what this page is for nor what to do
about it, so before anything is linked the page is a welcome instead: what
the version manager does, and the field that gets it started. The field
takes a direction, since a first link is as likely to be a child as a
parent, and the line under it explains which is which — the vocabulary
the sections use as soon as one exists.

The section buttons now say "Push to all" and "Pull from all", which is
what they do to the lists under them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
…ng it

The buttons now open a form, and the menus are where a run skips it. Push
asks what the version is called and how far it travels; pull, having no
version to name, asks how wide to cast — the parents somebody linked, or
every out-of-date reference the workspace has, which is where "Update all
references" now lives as well.

Every menu carries Quick push or Quick pull, and a child's carries Quick
recursive push, each running exactly what the form would have run from
its defaults.

Somebody who opens the form, changes nothing and pushes did not need it,
so the tip says where the shortcut is. It follows the insert menu's tip:
an info toast, raised only on an unedited run, quiet after three, and
silent for good once they take the shortcut themselves.

The navbar checkbox is gone with the mode it carried — recursive is a
checkbox in the form again, beside the targets it changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Each section's line of explanation moves onto its title as an info
bubble: it earns its room the first few times somebody reads it and
never again, and the list starts where the header ends.

A row's own button comes before its menu now, and is absent rather than
disabled where Onshape will not have it — the row says why in red, and
the menu shows the two runs greyed out, which is more than a dead button
was saying.

A link that cannot be opened says so in two lines, "Missing access" over
what it is that cannot be done, in place of the name it is not allowed
to show. The field that adds one asks for what it wants: an Onshape
document link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Adding a group and linking a workspace both start with a pasted url, and
both had their own half of the same parser. There is one now, in
`lib/onshape-url.ts`: it reads whatever the url names — the document, the
instance when there is one, the tab when there is that — and the two
callers take the part they need from it. A leaf, so it is testable
without reaching notifications or components, which is what the nine
tests beside it do: a document-only link, a version, a microversion, a
half-written instance, a configuration hanging off the end.

The group flow's "Failed to parse url." becomes the wording the version
manager already used, which says what to paste instead.

Every Onshape call the version manager makes already went through a
wrapper in `lib/onshape/endpoints`; each now carries the HTTP path it
actually calls, since `apiPath` builds them and the url is not otherwise
visible at the call site. `externalreferences` says why it is absent from
the codegen config: the spec does not carry it.

The routes say what they take, what they answer, and which Onshape
permission they need — the part of a route's contract the code does not
show. The reference doc gains the workflow, the feature, and the parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The five it uses that Onshape's spec carries are in the codegen config
now, and the committed reference dump is regenerated from them: creating
a version, listing workspaces, reading a permission set, and updating an
element's references, alongside the version listing that was already
there.

The dump confirms what was hand-authored from the 2024 Python rather
than from Onshape: `referenceUpdates` really is a list of from/to
document items carrying documentId, elementId and versionId;
`permissionset` really answers bare strings; creating a version really
takes name, description and the instance in the body and answers the
version. Nothing had to change.

`externalreferences` is confirmed absent from the spec rather than
assumed to be — its comment now says so, having been tried.

`defaultWorkspace` comes off the trim list while we are here:
`getDocumentWorkspaces` pulls `BTWorkspaceInfo` in regardless, so
keeping it costs two lines and confirms the `name` read off it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A link's row is a `CardTitle` and a `CardThumbnail` now, the same block
the groups, favorites and search results are built from — which is what
was making the first row a different height from its neighbours: a
hand-rolled title with a bare icon where every other list has a
fixed-size thumbnail. Every row measures the same, the one with no name
included: a workspace nobody can read gets no thumbnail asked for and
keeps the placeholder, which is the height anyway.

`CardTitle` gains two slots to make that possible: a `subtitle` for a row
whose second line is not a part number, and a `titleColor` for one whose
name is itself the problem. Both are absent everywhere else, so nothing
that uses it moves.

The field that adds a link is a row of the same table rather than a card
inset under it, which is what left it and the row above at their own
heights.

Workspace thumbnails come from Onshape directly, proxied because it
serves them only to an OAuth caller. Nothing is stored, rendered or
waited on — a workspace either has a picture or does not, and a document
with none answers 404, which the shared component already shows as the
question mark. Cached five minutes: neither immutable like a rendered
configuration nor worth refetching per row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A row for a document the caller can read but not write to used to say so in
red and hide its push button. The push route already refuses a run it cannot
finish, before it cuts the first version, so that check was a second copy of
one that has to exist anyway — and a wrong one whenever the permission
changed after the list loaded. The row now pushes like any other and the
refusal surfaces as the handled error it is.

`canPush` goes with it, which also drops a permissionset call per linked
workspace.

Both section stacks move onto a shared AppSection: the version manager had
its own copy of the library's accordion styles, plus padding around its panel
that inset its rows from the grid every other list in the app sits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Pushing was two clicks and a form even when the form had nothing to say: a
version named V3 and the one child the row is. The button now runs it, and
the form — a name, a description, the recursive box — is what clicking the
row opens, with the menus carrying it too. Opening the linked document moves
into the menu, which is where the rest of what to do with a link already was.

Also: a color per part of the app rather than per library alone, since the
dashboard and the version manager belong to neither — green and blue. The
header row takes its own hover highlight, which the wrapper around the
buttons was cutting short. The empty parent list's icon is colored like every
other zero state's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Linking the first document was a segmented control, a line of text that
swapped under it, and the list's own add row standing on its own. It is two
questions — which way round the link goes, and which document — so it is now
one card that asks them in that order: both directions as cards, each keeping
its description so the one not picked still says what it would mean, then a
labelled field and a button that names what it adds.

The field is the same one the lists end with; only its dress differs, so both
are built from one `useAddLinkForm`.

A section's own empty state goes left-aligned: one line saying a list is
empty should not take a list's worth of room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The two options carried their own title and description; they now carry the
push or pull arrow as well, on the same empty-state block the lists use, so
the thing that runs the link is what marks it. The card stops at 480px, which
only shows in a browser: in the panel it was already the whole width.

The field goes back to its placeholder alone — a label saying what a
placeholder already said.

An empty list drops its period and takes the direction's arrow in the page's
color, the broken-link icon having said only that something was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A link pasted into the wrong field was a delete and a re-add, which asks
somebody to find the url again for a mistake they can see. The row's menu now
turns it around: one row is one edge, so the move rewrites that row rather
than removing it and hoping the insert lands.

The exception is a pair that already links both ways, where turning this one
around would collide with the row that is already there — the move then drops
it, which leaves what was asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A pull moves this workspace's references onto a parent's latest version, so
edits the parent has not versioned are edits it cannot bring in. Onshape
counts them — `changesSinceVersionSave` on the insertables response — and each
parent row now carries that count as a badge.

Every `include` flag on that endpoint defaults to false, so asking for none of
them is the cheap call: Onshape enumerates nothing and answers the counters.
It is a query of its own rather than part of the list, being one call per
parent, and a parent Onshape will not answer for is left out rather than
failing the rest.

The operation is in Onshape's spec but its response sits under `default`
rather than `200`, so regenerating the reference dump adds nothing; the type
here is hand-authored from the spec's own schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The app runs inside Onshape, so the browser showing it is already signed in
there. The proxy route existed on the assumption that only an OAuth caller
could read a thumbnail; if the session cookie reaches a cross-site request
from our frame, the image element can fetch it and the bytes never touch the
worker.

Unverified against real Onshape — it is exactly the assumption that has to be
tested. It fails safe: a refusal lands on the element and the row shows the
placeholder a workspace with no thumbnail already shows.

Such a url cannot be probed by the fetch the thumbnail component makes first,
which is cross-origin and fails on Onshape's terms where the element would
load. So an external url skips the probe, and `onError` carries the refusal
instead — which also means a stored url whose bytes are gone now shows the
placeholder rather than a broken image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The version manager moves onto what cert now does: Onshape endpoints as plain
template paths with no `apiPath`, `getWorkspaces` from its own module, the
notice component as `app-notice`, `useUiState` and the per-tab
`useOnshapeLaunch`, `makeUrl` and the thumbnail url on the caller's own
Onshape origin, a modal that carries its own id, and the section accordion on
cert's sticky-header CSS rather than inline styles.

Its migration is renumbered past cert's three, and its D1 test renamed to
`.worker.test.ts`, which is what now decides that a test gets bindings.

`createAppTheme` takes the color rather than the library, since the version
manager's is blue and the dashboard's green whatever library is in view.
A push or pull was watched by asking every two seconds until it stopped.
The server already has a socket to every client, so it says so itself: the
route pushes the run as it starts, the workflow pushes how it ended, and the
client asks once when the page opens and after a reconnect.

A run belongs to an Onshape workspace rather than a library, so a client's
socket is tagged with the workspace it was launched in as well — the message
names somebody else's document, and only the people already in it hear it.

`workspaceKey` moves to the contract, being what the graph walk, the stored
job and now the socket tag all spell a workspace as. The finished-run toast
takes an id, so a second run replaces the first's result rather than stacking
under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The id changes with the name, since it is what the library is keyed by
everywhere and a stored one is being reset anyway. Its rows move with it: a
library's groups, insertables, favorites, jobs and every analytics table name
it by id, and SQLite does not cascade an update, so the migration writes the
new row, moves the children, and drops the old one.

The program select still offers this library under FTC, and analytics still
reads its season and vendors as FTC's: the rename is the library's name, not
what is in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A tab strip said several libraries were open at once when one is, and it took
the room the app's own pages need — the version manager was already scrolling
off the end of it in Onshape's panel. The libraries move into a menu that
names the one in view, and the tabs are left to the pages that are not a
library's.

The brand keeps its mark and drops its name in that navbar: the library beside
it says where you are, and a few hundred pixels will not carry both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Onshape's api is at v17 now, and with it webhooks for a workspace being edited
and for its metadata changing — which is what a linked workspace's row is made
of. Opening the panel asked Onshape for every link's document name, workspace
name and pending-change count every time, three calls a link and a fourth per
parent.

The names and the count are cached in KV, and a transient webhook on each
linked workspace drops both entries when it is edited, renamed or versioned.
Onshape drops a transient webhook that goes quiet, so the entries expire too —
a week for a name, an hour for a count — and the next miss watches again.

Permissions stay uncached: what a document is called is the same for everyone,
what you may do with it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
One document in the shape the others take: what a parent and a child are, the
two flows and the order a recursive push runs in, every store and its lifetime,
the invariants a change should be read against, and the decisions that look
odd without their reason — a table of its own with no foreign keys, a workflow
rather than the request, and the buttons running while the form sits behind
the row.

It ends with what is still unproven: nothing here has run against real
Onshape, and `externalreferences` is absent from Onshape's spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Their page picker, illustrated zero state, versioning pull and per-row change
count come over as they are; what was written against the pre-cert tree is
ported onto cert's: the notice component, `useUiState`, the modal that carries
its own id, `styles.module.css` in place of the style constants, and the
Onshape endpoints as plain template paths.

Where the two disagreed about the same thing, theirs wins: the thumbnail goes
back through the proxy (`api-base.ts` with it), the change count moves into
the links response rather than a route of its own, and the brand folds its
wordmark by width rather than by a prop. Resuming in the version manager is
cert's `tabId`, so their column and its migration are dropped; the entry
redirect now checks the launch has a workspace, without which `/` and the
version manager would have sent each other back and forth.

The push-based job status, the KV caches and their webhook, the API version,
and the `ConfigLib` rename all carry over onto their code.

The page picker is the dropdown alone, as asked: the tab row it fell back to
above 790px is gone.
AlexKempen and others added 29 commits October 3, 2026 17:31
…background

- The page picker's menu carries the same blue dot on the version
  manager's icon as the picker button does, beside its New badge.
- The Launch tunnel task reveals its terminal only on failure, so
  starting the servers leaves the dev server's output in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
InfoTooltip takes the colour of what it sits in where that has one; a
build-check callout passes its severity's, so an info check's icon is
blue rather than the library's green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Configured thumbnails render only from a group's thumbnail workspace, but a
load skips a group whose version is unchanged, so a group last loaded before
thumbnail workspaces existed never got one. requestRender then answered
"rendering" without starting anything, and the insert menu sat on the default
thumbnail waiting for a push that never came.

A load now skips a group only when it also has a thumbnail workspace
(isLoaded), so any plain Reload branches one. A render asked for in the
meantime is refused with a 503 rather than left waiting; the render can't
branch one itself, since that takes edit access the caller may lack. The
by-id fetch also passes skipDefaultImage, so an unrendered size fails and is
retried rather than storing Onshape's stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Merging the earlier navbar redesign branch took its whole navbar, which
predates cert's later edits to it. This puts those back: no library-load
spinner in the navbar (rows show their own), the comment guidance pass,
no aria-label on the clear button, the theme's search icon size, and an
empty search query when none is stored. useIsLibraryLoading had no other
caller, so it goes too.

Audited the rest of cert against this branch line by line; every other
difference is a deliberate change made here since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
A configured render that loads but shows the default means Onshape answered
the configuration's thumbnail id with the element's own image, and the
workflow stored it immutably. The workflow now compares what Onshape sends
with the element's stored default and retries while they match, so a
configuration only ever stores its own render; if Onshape never sends one
within the minute, nothing is stored and the preview fails.

requestRender logs the thumbnail id Onshape gives each configuration, and the
workflow logs each default answer, so the logs show whether insertables
ignores the configuration (one id for every configuration) or the by-id
endpoint serves the default for a configured id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
URLSearchParams spells a space "+", which Onshape reads literally: a
configuration like Length=0.1524 m reached insertables as 0.1524+m and was
ignored without an error, so every quantity configuration got the default's
thumbnail id and rendered the default. The Onshape client now builds its
query with toQueryString, which spells spaces %20; a literal "+" is already
%2B, so nothing else changes.

Drops the default-bytes guard and the render log added to find this: the
logs showed one thumbnail id for two configurations, and the guard never
fired, since Onshape's default-configuration render is not byte-identical to
the stored default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Focusing the box swapped its rounded display for the full expression after
select() had run, and changing an input's value drops its selection, so a
click on a value shown rounded left the caret at the end. The selection now
happens in a layout effect, once the expression is in the box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
On each render request, asks insertables for the thumbnail id with no
configuration and with the configuration spelled once-encoded,
double-encoded (as the old Python app sent it) and with "+" for spaces,
against both the thumbnail workspace and the version, and logs them all.
Revert once the logs show which one Onshape reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Probing insertables showed the thumbnail workspace answering three different
configurations (Length=0.1524 m, Length=0.161925 m, and an enum plus a length)
with the same predictableThumbnailId, distinct from the unconfigured one, so
every configuration rendered as the same image. On the version the id moves
with the configuration, which is where the original app asked. Double
encoding is a 400 and "+" reads like %20, so the encoding was never it.

This is a design change: configuration ids are now resolved on the version
while element and group thumbnails stay on the thumbnail workspace, and the
thumbnails doc's invariant and decisions say so. Renders no longer need a
workspace, so a group without one is no longer refused and loads stop
reloading unchanged groups to branch one; the probe goes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Onshape's own dialog polls /thumbnails/d/{did}/w/{wid}/e/{eid}/c/{key}/s/{size}
until it stops answering 404, where the key is base32 of a FeatureScript map
of the configuration (a quantity's base value as its double's bits, an enum
option with its type). Insertables returns that key as the response's
top-level configurationKey, so a render asks insertables in the thumbnail
workspace for it and polls that url. The predictableThumbnailId flow goes:
in a workspace it named one id for every configuration, and on the version
the workspace's reliable rendering is lost.

This puts back what rendering in the workspace needs: a load branches a
thumbnail workspace for a group without one, and a render for such a group
is refused rather than left waiting. Render instances take a new id prefix,
since an older instance restarted would carry the old parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Logs the top-level fields of the insertables response a render reads its
configuration key from, with the key base32-decoded, and each failed try at
the configured thumbnail. Revert once the logs show why renders time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Onshape names a configured render by the double it computes from what was
typed: 2.25 in is 2.25 * 0.0254 = 0.057150000000000006, while the key's
0.05715 m is 0.05715, one bit apart. Sending the key's base-unit spelling
made insertables name a render Onshape's dialog never asks for, so ours was
never found; 5 in worked only because 0.127 is exact either way.

The render request now carries the selection. The server makes it whole,
derives the key itself (so a caller can no longer store one configuration's
picture under another's key), refuses the element's default, and sends
Onshape renderOverrides: the overrides as entered, less derivation variables.
toKey is built on the same overrides.

This tightens an invariant: a key is never sent to Onshape as a
configuration, a render's included, in configurations.md and AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Onshape's insert dialog asks /c/{key}/s/{size} with t (the microversion) and
rejectEmpty=true; the render workflow now sends the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
…nder

Run with an Onshape API key. Each probe picks a length nobody has opened,
asks insertables for its configuration key, makes one candidate call (none,
parts, parts with thumbnails, configurationencodings then the /ac/ thumbnail,
or partstudio shadedviews), then polls /c/{key} for two minutes and reports
how long it took to load. Delete once the render path is settled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Back to how prod renders, in a workflow rather than its Durable Object
queue: a configuration's thumbnail id is resolved on the insertable's
version (the selection as entered, escaped once), and the workflow fetches
it by id, one size at a time, since asking Onshape for a second render
abandons the first. Element and group thumbnails are read from the version
and, when it won't give one up, from the document's own workspace, during a
load and on Reload thumbnail alike.

The per-version thumbnail workspace goes: no load branches or deletes one,
groups.thumbnail_workspace_id is dropped (migration 0009), a group is no
longer reloaded for lacking one, and createWorkspace/deleteWorkspace go with
it. Load thumbnail steps return to three retries 10-40 s apart, since the
16-minute schedule was for fresh branches. The /c/ configuration-key render,
its temporary logging and the probe script are removed.

This is a design change: the thumbnails doc's invariant that every thumbnail
is read from the thumbnail workspace is replaced by version-first reads, and
a render workflow asking for one size at a time is a new invariant; both are
under Decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The render workflow asks Onshape for each size every 2 seconds rather than
every 5, still for about a minute (30 retries), so a render that lands is
picked up sooner. A 429 still waits its Retry-After.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
One section, stage by stage: the Onshape client, a load's default
thumbnails, Reload thumbnail and its toasts, serving, a configuration render
on the server and the client, stored images on the client, push reconnects,
and the insert menu's wait tip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The section now walks through each situation (a load, browsing, picking a
configuration, a saved favorite, an editor's reload, a lost connection) and
says how long the app keeps trying, what the person sees meanwhile, and what
happens when it gives up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The entry route redirected a resumed utility tab by href, which carries its
own search and dropped the one passed beside it, so a panel opened with the
version manager last lost Onshape's launch: the page found no workspace and
sent it to the library, and with the launch adopted only once per load, the
page picker never offered the version manager again in that panel. It now
redirects by route, as a library tab does, so the launch rides along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
- Version name is required and opens with the suggestion filled in; the
  form sends no name while it is the untouched suggestion, so each
  document a recursive push versions is numbered from its own history.
- Focusing a version form field or the link url selects its text.
- Linking a document already linked either way is refused with a 409;
  the success toast names the linked document and workspace.
- Linking also clears the version manager dot.
- Toolbar refresh button: forgets the linked workspaces' cached names on
  the version manager, reloads the library elsewhere.
- Insert location is hidden while the version manager is showing.
- Shorter parent/child tooltips; toasts end with a period throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
- Recursive pull (PullScopeKind.ANCESTORS, or ONE with recursive): the
  closure above this workspace in topological order (pullOrder), each
  parent moved onto its own parents' new versions and then versioned,
  this workspace last. Offered as "Quick recursive pull" and as the pull
  form's Recursive option. Design change: the doc said there was no
  recursive pull; it now describes one, with the reason under Decisions.
- collectEdges walks links either way; graph.ts shares one post-order
  walk between pushOrder and pullOrder.
- A run forgets the cached change count of each workspace it versions or
  moves, so a parent's changes badge updates once the run ends.
- The page's loading and error states are full-page notices (PageLoading).
- The version name is focused and selected once the suggestion arrives.
- The refresh button is shared with the dashboard (RefreshButton),
  spins in the page's color, toasts when done, and shows on the version
  manager alone.
- Drop the summary pills from the run details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
- Bump every @Mantine package to 9.7.0 (charts stays pinned exactly).
- AppHoverCard is one HoverCard for mouse and touch: 9.7's
  `events.touch`, on by default in the theme as a Tooltip's is, replaces
  the (hover: hover) query and the Popover it fell back to.
- Cards holding controls (insert location, build status) are
  `interactive`, which 9.7 needs to keep one open while the pointer
  moves onto it.
- `onDismiss` swallows the click that ends a dismissing tap, so a tap
  outside a card closes it without opening the row underneath; mouse
  clicks and Escape pass.
- Chrome follows a tap's click with a mouseleave, which floating-ui
  closes the card on; `holdOpenAfterTap` drops that one event so a
  tapped card stays until dismissed.

useAppShellResize is left out: it drives drag-to-resize sections, and
the shell's only section is a header sized by its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
withoutDeprecated drops, from a document's contents as the load reads
them, every tab whose name contains "deprecated" (any case) and every tab
inside a folder so named, at any depth. Tab selection, removal, ordering
and the document thumbnail all read the filtered contents, so a stored
insertable whose tab is renamed or moved into such a folder is removed
like a deleted one. OnshapeElementGroup gains Onshape's `groupName`.

Configuration options and parameters are not filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
createShellGroup writes the group with a NOT_LOADED build issue, so a
document whose first load never starts (requestLoads failing after the
shell is written) still shows something wrong and asks for a Reload. The
first successful save recomputes the group's issues, clearing it; a
failed load replaces it with LOAD_FAILED. The build card treats it as it
does LOAD_FAILED: a group never loaded has nothing to unhide.

Also drops the version manager's reference to the removed job-tracker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
The name read too close to an existing push service. Migration v3
renames the class in place (renamed_classes), so the namespace and its
sockets carry over; the binding is now CLIENT_RELAY and the module
features/push/client-relay.ts. The push feature and its messages keep
their names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
…sion held

Design change: replaces one LoadDocumentWorkflow per group, and the
load_jobs table, with one ReloadLibraryWorkflow per request, scoped to
the group ids it names. loading.md's invariants and decisions are
rewritten to match.

- One reload holds a library at a time (`library-reload:` in KV); one
  asked for meanwhile sleeps 30 s between claims until the library is
  free, so nothing is queued to be lost and no group is loaded twice at
  once. A holder found dead is taken over and its unfinished groups
  flagged LOAD_FAILED. KV has no compare-and-set: two reloads claiming a
  free library in the same instant both run, which is accepted.
- Every group of a reload runs at once under its own step names
  (`prefixSteps`) and shares one Onshape budget, LOAD_CONCURRENCY now 30
  across the reload; the search index is rebuilt and the version bumped
  once, at the end. The workflow's step limit is raised to 25,000.
- Job status comes from the claim's remaining groups and the queued
  reloads (`library-reload-queued:`), checked against Workflows on read.
- Every webhook version is held: there is no longer an approval
  setting. A held reload waits up to a week for Approve, or an admin's
  Reload, then loads anyway.
- Migration 0010 drops load_jobs and libraries.approve_versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
Production is at 0004; cert and local databases are disposable. The six
migrations after it (0005_library_loading through 0010_reloads_in_kv)
created load_jobs, approve_versions and thumbnail_workspace_id only for
later ones to drop them, so they fold into one,
0005_teams_webhooks_and_version_manager: drizzle's diff from 0004, with
the events rebuild copying only the columns 0004 has, then the ConfigLib
rename and the analytics backfill carried over by hand.

The schema it leaves is identical to the old chain's, and seeded 0004
data upgrades correctly. reset-db.sql now drops every table, including
load_jobs, so cert can be reset before this deploys: its d1_migrations
already records the old 0005.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
…ad-state gaps

Access:
- Reload (outdated documents) and approving held versions are an
  editor's, since editors see held and failed documents; forcing every
  document stays the owner's. auth.md's table moves them.

Thumbnails:
- requestRender settles the selection as the insert menu does
  (normalizeSelection) before deriving its key, so the key always matches
  the one the menu waits on, whatever the caller sends.
- The render workflow falls back to an admin's session of the
  insertable's library once the requester's stops working: a restart
  reruns with the first requester's session, so a render once asked for
  by someone since signed out could never finish.
- Reload thumbnail overwrites each size only once Onshape gives up a new
  image, rather than deleting first, so a failed reload keeps the
  picture the row names. A group's tab is picked as a load picks it
  (documentThumbnailElement, deprecated tabs left out), and a group that
  has never loaded is refused. The default-workspace lookup is shared
  (toDefaultWorkspacePath).

Reload state:
- A failed read of a reload from Workflows is taken as live, not dead:
  every mark names a reload that exists, so a blip no longer abandons a
  running reload or drops a held one from the approve list. requestReload
  removes its mark if the instance can't be created, which retires the
  one-minute grace period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VDUXqzz4JkJeiauwQweyk
@AlexKempen
AlexKempen merged commit 50be54e into cert Oct 21, 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.

2 participants