Stamp the failed marker on the aggregated summary part server-side, mirroring
how ON_RUN_STEP_CLOSED already stamps runStepStatus. The SDK aggregator ignores
a complete event that carries no summary, so an errored round's partial deltas
were saved unmarked and a reload re-rendered the truncated text under
"Conversation summarized".
Skip memory attachments that carry a toolCallId in the message-level
MemoryArtifacts decoration. Those calls already render inline as a MemoryCall
card with the same key, value and outcome, so one mutation appeared twice.
Attachments with no originating call keep the decoration as their only surface.
Keep a reasoning-bearing group expanded when Open Thinking Dropdowns by Default
is on. The folded-in THINK part only renders inside the group body, which is
not even mounted while collapsed, so the preference had no visible effect until
the action group was opened by hand.
Give same-named place results unique keys. Several branches of one chain share
a name and often carry no identifier, so rows whose address, rating and map
target all differ collided on one React key.
Compose the shared Button for the compact reasoning disclosure, keeping only
the row geometry local so the focus ring and disabled handling come from the
shared recipe.
Lazy-mount the compact reasoning body with useLazyCollapseBody, as the
standalone panel already does. Collapsed is the default whenever thoughts are
hidden, and a streaming THINK part re-renders on every delta, so the full text
was being mounted and laid out behind an invisible panel.
Classify grouped memory failures with isMemoryFailureOutput. Those tools report
failure in prose that generic isError parsing does not recognize, so a
persisted call with no terminal status showed a failed card inside a group
reporting success with no failure count.
Announce the diff statistics to assistive technology. The +/- glyphs carry
their meaning through color and sign, so aria-hidden left screen-reader users
without the change summary; the counts are now also exposed as localized
screen-reader text.
Matching the marker names alone was wrong: deleting a real `-- old_text`
comment line, as SQL and Lua use, is prefixed to exactly `--- old_text`, so the
previous guard discarded a genuine changed line from both the preview and the
statistics. The pair is now skipped only in separator position, adjacent and
immediately followed by a hunk header, which no run of genuine content
satisfies. Covered by a test asserting such a pair inside a hunk survives as
content.
Also drops the em dashes from the comments added over this review pass.
Recognize the batched-edit separator pair for every edit in a diff preview.
formatEditPreview writes a `--- old_text N` / `+++ new_text N` pair before each
edit's `@@`, but the parser gated on `inHunk`, which is never reset, so only
the first pair was skipped and every later one was counted as a deletion plus
an addition and rendered as a marker row. Matched by name instead, which leaves
content lines that merely begin with `-` or `+` alone.
Compose the shared Button for the popover's type-below hint, suppressing only
the fill the way Summary's quiet text buttons already do, so the focus ring and
disabled handling come from the shared recipe.
Render an answer box that carries only snippetHighlighted. It is independently
optional from snippet, and the details card opens for any answer box, so such a
result previously showed a heading with an empty body.
Compose the shared Button through HoverCardTrigger's asChild for the web search
details trigger, which recreated rounding, hover colors, focus ring and motion
locally. This also replaces Radix's default anchor with a real button. Only the
hover-card reveal and the icon geometry stay local.
Copy compact reasoning through copy-to-clipboard for the same reason as the
handoff control: navigator.clipboard is undefined on a non-secure origin, where
dereferencing it threw before anything was copied.
Document the streaming-peek fade as a narrowly scoped custom-CSS exception. It
is a mask-image stencil rather than paint, so only its alpha channel is read
and no theme role could meaningfully restyle it.
Move the disclosure chevron appearance into @librechat/client as
disclosureChevronVariants, owning the hover/focus reveal, the motion and the
open rotation. The four tool, search, memory and reasoning surfaces now pass
their expanded state and keep only icon geometry local, so a change to the
shared behavior reaches all of them. Panel spacing stays feature-local, being
genuine layout.
Require a navigation target on image results, not just an image source: a
thumbnail-only entry passed the previous guard and then rendered a styled
anchor with an undefined href, looking actionable but opening nothing.
Exclude closed run steps from the pending-auth predicate. ToolCall already
hides the sign-in button on a terminal status, so a persisted OAuth call whose
progress never reached 1 left the group showing a trust warning with no
authentication action behind it.
Copy handoff instructions through copy-to-clipboard, as the sibling
message-content controls do. navigator.clipboard is undefined on a non-secure
origin, where dereferencing it threw before the rejection handler could run and
the button silently did nothing.
Reuse the shared CopyButton for the handoff instruction copy control, which
recreated hover, focus-ring, opacity, rounding and copied-state appearance
locally. The icon crossfade and tooltip now come from the shared primitive and
only the reveal-on-hover behavior stays local.
Keep unlabeled commentary out of collapsing tool groups. An unlabeled group
auto-collapses once complete, so folding commentary in hid the assistant's
prose behind a generic action header. The flush now splits the block at
commentary and groups each surrounding run on its own, restoring the shape the
previous fallback had while keeping reasoning folded in.
Drop shopping results without a link and places with nothing to name, for the
same reason the image results are filtered: every provider field is optional,
and an unusable entry still made the array nonempty, so the card rendered an
anchor with no destination that looked actionable but could not be opened.
Reuse the shared Button primitive for the three question move controls (the
chat card's chevron and both popover chevrons), which each repeated the same
hover, focus-ring, color and rounding recipe locally. Only the icon geometry
stays local.
Drop provider image results that carry neither thumbnailUrl nor imageUrl.
Both are optional in the ImageResult contract, and an unusable entry still
made images.length nonzero, so the card rendered an anchor and img with
undefined destinations instead of omitting the result.
Gate the memory deletion confirmation on a completed phase. The panel opens as
soon as the key streams in and the phase can still settle as cancelled, so
"Memory deleted" was claiming a removal that never happened while the header
correctly read Cancelled.
Key pending answer drafts by action id instead of a single shared slot. With
Save drafts disabled, editing a collapsed question in one conversation and
then editing another paused conversation's question overwrote the only slot,
and the action-scoped reader then showed the first card an empty box with its
unsent answer unrecoverable. Entries are dropped once answered.
Replace the raw green/red diff palette in DiffView and CodeWindowHeader with
the existing status-success/status-error semantic roles, so a customized or
versioned theme moves the diff colors with it.
Compose the shared Button primitive in the ask option rows instead of
recreating hover, focus-visible and disabled appearance locally, and swap the
raw text-white on the checked badge for text-on-status.
Prefer a THINK part's generated reasoning_label in the grouped rendering path,
matching the standalone Reasoning path, with the generic Thinking/Thoughts text
only as a fallback.
Derive grouped tool completion and failure from the run step's terminal status
through the shared resolveToolCallPhase helper, so a step the run closed as
failed with empty or benign output no longer reads as running and then reports
success while the card beside it shows a failure.
Carry an explicit failed flag on a summarize round that ended in error, so the
partial deltas already streamed into the slot are no longer presented under the
"Conversation summarized" label.
Also drop seven orphaned English locale keys this branch left unused, which
were failing the unused-i18n static check.
empty_messages and final_context_overflow are structured errors thrown by
the agents SDK, but they were missing from the typed-error dispatch table
and surfaced as raw JSON inside the generic failure text. Map both to
localized copy: empty_messages keeps its token budget breakdown in a
details block, final_context_overflow interpolates the projected and
available token counts.
A tiny user-set maxContextTokens re-triggers summarization on every graph
step (the summary allocation rounds to nothing and history still
overflows), burning dozens of LLM calls until the recursion limit aborts
the run with an opaque LangGraph error. Fall back to plain pruning under a
1024-token floor so the run either fits or fails fast with the actionable
empty_messages breakdown.
Failed summarize completions spliced the in-flight summary part out of the
content array, remounting every later index-keyed part (collapsing whatever
the user had expanded mid-stream) and breaking the content-position ==
step-index invariant that streaming writes rely on. Finalize the part in
place instead and scope completion to the owning step's slot so
back-to-back rounds cannot clobber a newer in-flight part.
The popover carried both a chevron and a close button. Both left the
question pending and both moved it into the chat, so their only real
difference was invisible: the chevron kept the composer wired up as the
answer box while the close button released it. Two icons for one visible
outcome, with the meaning hidden in a placeholder change somewhere else.
Collapsing now does both jobs at once. Moving a question to the chat hands
the composer back for normal messages, and the card's chevron re-arms it,
so the popover's visibility and the composer's role can never disagree.
Escape maps to the same path and the dismissed-ids state is gone. Both
chevrons get a tooltip and a matching label, since no glyph conveys this.
Also fixes two behaviours found along the way: the card's Skip was a
silent no-op once the question left the popover, because it routed through
a helper gated on answer mode instead of the live pause, and clicking a
multi-select row moved the keyboard highlight, leaving a row painted as
selected after it was unchecked.
The popover and the chat card rendered the same question two different
ways: numbered rows in one, wrapped chip buttons in the other, and the
panel was painted with hardcoded white/gray instead of theme tokens.
Both surfaces now render one AskOptions list, so numbering, check badges,
rounding and hover states match wherever a question appears. The card
groups its prompt with the description, styles the answer box like the
composer, and gains a Skip button beside Submit so declining does not
require the popover. While the popover holds the question the card keeps
a hidden copy of itself in the thread, reserving the exact space the card
will need so nothing reflows when the question moves back.
Wrap a state update in startViewTransition (with a synchronous flush) so
elements sharing a view-transition-name animate their position, size and
shape between two states instead of swapping.
The composer band gets its own named, opaque snapshot group stacked above
the morphing element: without it a surface travelling between the thread
and the composer paints over the input and bleeds through the strip below
it. Falls back to a plain update where the API is unavailable and for
reduced-motion users.
The button lived inside the scroll container with sticky/float
positioning, so horizontal scrolling dragged it through the command
text. It now sits absolutely in a non-scrolling wrapper around the
scroller and stays in the corner on both axes.
Place rows were the only vertical without a destination. Each row now
links out to the user's native maps app: Apple devices (detected via
the existing isMacPlatform check) get maps.apple.com with the query
and coordinates, everything else gets a Google Maps search link. Both
are keyless https links that degrade to a web map, so a misdetected
platform still lands somewhere useful. Rows pick up the same hover
treatment as the other linked results.
Image, shopping, and place results were only visible after expanding
the web search row, and disappeared entirely inside a collapsed tool
group. The strips moved into a shared verticals module that renders
in two spots following the attachment pattern: always below the row
for an ungrouped search, and hoisted below the group (visible while
collapsed) for grouped calls, where the row suppresses its own copy
via hideAttachments. The disclosure panel is back to sources only.
Serper vertical results were parsed into the artifact and dropped.
The expanded panel now renders them when present: an image strip of
rounded thumbnails at true aspect ratios (sized from thumbnail
dimensions, no layout shift), a shopping card carousel with product
image, title, price and store, delivery note, and star rating, and a
place list with name, category, address, and rating. All link out,
use semantic lists with localized labels, and cap item counts.
Expansion now keys off any renderable content, so a verticals-only
search still opens.
The inline answer card read as assistant prose sitting inside the
source list. The panel is back to sources only; an Info trigger at the
right edge of the row (hover-revealed like the chevron, focus-openable
for keyboard) opens a hover card with the search query, the source
count, and the provider answer box when present. WebSearch now
receives the tool call args to surface the query.
The panel only showed favicon, title, and domain while the search
artifact carries much more. Source rows now include the result snippet
(clamped to two lines) and date under the domain, and a Serper answer
box renders as a lead card above the list with its title and snippet.
Streaming behavior is unchanged; the answer box is attachment-only
since it never carries citation anchors.
The expanded source panel sat flush against the 22px favicon stack,
which overflows the 20px row. The panel now opens with a 6px top
margin while the row keeps the shared my-1 rhythm.
set_memory and delete_memory rendered through the generic tool card,
reading "Ran Save Memory" with raw parameter chips like an unknown MCP
tool. A new MemoryCall part gives them first-class rows: "Saved
memory" / "Deleted memory" with the key as a quiet subtitle, the
Brain icon, and an expanded panel that mirrors the memory attachment
display (bold uppercase key over the stored value, or a deletion note),
with error output shown inline when a write fails.
The dual old/new gutters left one column blank on every add or delete
row, reading as a stray number floating far from its marker. Each row
now shows the one relevant line number (old for removals, new
otherwise) right beside the +/- marker, and the scroll container is
vertical-only since lines wrap.
The row offered an expand affordance whenever raw output existed even
when no sources parsed, opening an empty panel. Expansion now keys off
parsed results, which also covers attachment-only sources that arrive
without text output, and collapses if results disappear.
Rebuild MemoryArtifacts on the shared disclosure row (useExpandCollapse
grid animation, hover-reveal chevron, h-5 header) in place of its
bespoke ResizeObserver height animation, and simplify the expanded
panel: section headings dropped in favor of bold memory keys, a single
p-3 inset instead of double padding, and the now-unused heading
translation keys removed.
Replace the highlighted-text diff in file authoring output with a
line-based renderer: red and green tinted rows with +/- gutters,
old/new line numbers when hunk headers carry them, and diff noise
stripped (file headers, streamed old_text/new_text markers, bare @@
separators become dividers). The code window header shows the file
name with +N/-N change stats next to Copy Code, and file authoring
rows adopt the path-first arg parsing and shared panel spacing.
Group headers now describe the activity performed: homogeneous search
groups read "Searched the web" or "Searched web and files", mixed
groups read "Ran N actions" with per-label repeat counts (Create File
x2) and a failure tally in the detail line. Generic tool rows resolve
their label through getToolDisplayLabel, the OAuth trust warning moved
into a shared ToolAuthWarning that a group renders once instead of per
call, and the group body is a flex column so row margins stop
collapsing unevenly between rows.
Map skill, read_file, create_file, edit_file, set_memory, and
delete_memory to localized display names so summaries stop showing raw
tool ids, and give the memory tools the Brain icon the Memories panel
already uses. read_file and skill rows parse the arg the backend
schemas actually emit (path, with file_path as a legacy fallback;
skillName) and fall back to a generic noun instead of rendering an
empty name in their labels.
Extract the hover-reveal chevron and expanded panel spacing into
disclosure.ts and align every tool activity row on the same layout:
h-5 header, my-1 rhythm, full-width click target, and a 200ms chevron
rotation. Rows rendered through ProgressText now stretch across the
message column (the absolute content layer was shrink-wrapping to the
text), and the copy buttons in thinking and output panels anchor to
the row instead of the panel corner.
- Replace the subagent dialog with a right-side panel that shares the
artifacts slot (currentSubagentRunId + subagentPanelVisibility atoms,
a subagentRunsState registry, and a useOpenRightPanel cross-clear so the
two panels are mutually exclusive)
- Rebuild the inline subagent card to be tool-consistent, preview a single
live/result line, and open the panel (auto-focusing on stream arrival)
- Relocate the originating prompt to a collapsed, smoothly-animated
disclosure under the panel header
- Gate the Thoughts copy button to expanded state and move the floating
bar to a scroll-out reveal, matching the code-block pattern
- Fix the reasoning expand/collapse jump by animating the trailing gap
inside the grid track instead of an expand-only margin
- Treat reasoning (Thoughts) as transparent to tool grouping so interleaved
thoughts fold into the tool group instead of splitting it
- Group a lone tool call that has reasoning (e.g. a skill) so it gets the
same collapsible chrome as multi-tool groups
- Count only real tool calls for the group header; add a reasoning indicator
and a singular 'Used 1 tool' label
- Rework the in-group Thoughts panel: tool-row sizing, rounded content,
header copy button, and a floating collapse/copy bar
- Round the floating thinking-bar buttons
- Refine the agent handoff row and instructions panel with consistent
spacing and a copy affordance
* 🗓️ fix: Surface a Run Now Scheduled Chat in the Sidebar
Run Now left the conversation list untouched, so the chat it started was
invisible until a reload. Refetching the list on success would not have
fixed it either: run-now answers once the trigger delivery is durable, and
the generation that writes the conversation starts after that, so the
refetch comes back without the chat.
Seed the row instead, from the id the response already names and the cached
schedule's name and agent, resolving the destination project the same way
the fire path does so it lands in that project's list and no other. Report
the started generation to the active-job list and re-arm it, since it stops
polling while nothing is listed and is the only thing that can show the row
as running.
* 🩹 fix: Wait for the Run to be Admitted Instead of Seeding a Row
Codex found four ways the optimistic row goes wrong, three of them inherent
to seeding a chat the server has not created: it is assembled from a client
snapshot of the schedule that another tab may have moved on from, it
outlives a delivery that never admits, and it can be opened before the
conversation exists, which bounces that tab to a new chat it never recovers
from.
The conversation route answers 404 until the generation's first write
commits and applies the same visibility rule the list query does, so it is
an exact admission probe: a 200 means the chat is listable now and carries
the server's own row. Wait on that instead, over a bounded set of probes,
and add what came back. Nothing is written for a run that never admits.
Re-arming the active-job poll moves to admission too, where a generation is
known to exist, rather than at the click, where the thirty-second grace can
lapse before a retried dispatch ever starts one.
* 🩹 fix: Cover the Delivery Retry Window and Keep Transient Probes Alive
Two follow-ups from codex on the admission watch.
The probe budget stopped at forty-three seconds, inside the window the
server is still willing to admit in: the trigger engine allows eight
attempts backing off from a second and doubling, about two minutes of
retries at worst. Widen to roughly five minutes across eleven probes, which
covers that twice over. A dispatch refused with a long Retry-After can still
be re-driven hours later; a watch held open for that is worse than the list
refetch that already covers it.
Ending the watch on any non-404 also gave up on failures that say nothing
about the run: a 5xx, or a request that never got a response, is the probe
failing while the durable delivery is still free to admit. Only a 4xx that
is not 404 settles the matter.
* 🩹 fix: Fence the Admission Watch to its Session and Queue its Title
Two more from codex, both real.
The watch can run for minutes, and signing out or signing in as someone else
empties the query cache in between. A response arriving after that wrote one
user's chat into another's sidebar. Fence it: capture who the cache belonged
to when the watch started, and check again before probing and before writing.
A conversation admitted mid-run has not been titled yet, so the row lands as
"New Chat". The foreground paths hand that to the title queue, which already
owns the timing the server was configured for; a scheduled run has no
foreground path, so hand it over here instead of leaving the placeholder
until an unrelated refetch.
Not taken: widening the probe budget again to cover the trigger host's
per-attempt timeout. That is the third arithmetic for the same knob, and no
fixed budget closes the class — a Retry-After refusal can defer admission by
up to a day. The ceiling is deliberate and the miss degrades to the next list
refetch.
* 🩹 fix: Honour Retryable Statuses and Keep Archived Chats Out of the Sidebar
Two narrow ones from codex, plus a test-coupling bug they exposed.
408 and 429 ask to be retried by definition, and a proxy in front of the app
can raise either without the run being involved, so a blanket "any 4xx but
404 is terminal" abandoned a watch over something that was going to resolve.
The conversation route serves an archived chat while the list query filters
one out, so a run archived from another tab between its first write and the
next probe would land a row in the sidebar that no refetch agrees belongs
there. The archive was deliberate; leave it alone.
The archived-row assertion passed at first against the missing guard, because
`clearAllMocks` leaves an undrained `mockRejectedValueOnce` queued for the
next test, where it answered the first probe. Reset implementations between
tests so one test's leftovers cannot vouch for another's.
* 🩹 fix: Bind the Session Fence Late When the User Query is Still Loading
The fence I added last round read an unloaded user cache as an identity. A
run started before that query resolved captured `undefined`, and the
account's own arrival then looked like the cache changing hands, so the
watch abandoned a run it should have tracked — the fence failing closed on
the feature it was protecting.
Run-now answering 200 is itself proof a session existed at the click, so an
empty entry then is a cache still loading rather than nobody signed in. Bind
the owner on first sight instead, and fence only once one is known.
Also restores import order in the spec, which is what Static checks caught:
a later edit added an import without re-running the sorter.
* 🩹 fix: Refuse the Write While the Cache Owner is Unknown
Last round's fence compared only once an owner was known, which left the
hole it was built to close: a run started before the user query resolved
kept an undefined baseline, so a sign-in arriving while the probe was in
flight was never noticed and one account's chat could seed another's cache.
An unknown owner cannot be compared, so it cannot be seen to change either —
"still loading" and "changed hands" are the same reading. Refuse the write
instead. A late-resolving query still binds and still tracks, because that
happens before the probe; only the genuinely ambiguous case is dropped, and
every caller of this has a signed-in session.
The tests now sign in, which is the only state this runs in, and the one
that starts signed out keeps its premise so the boundary stays pinned from
both sides.
* 🩹 fix: Write the Session Fence Once and Mirror Foreground Project Bookkeeping
Four rounds of fence patches each fixed one cell of a three-state problem
and broke another. Write it as one state machine instead, on the property
that makes it tractable: the server enforces ownership on the probe itself,
so a request sent under one account can never return another's chat, and
the only dangerous response is one sent under the account that clicked and
landing after someone else signed in. So the owner is bound only from a
reading taken before a probe goes out, a successful probe with no baseline
yet is skipped rather than abandoned, and once bound any different reading
ends the watch.
Mirror the foreground path's project bookkeeping: a chat filed under a
project moves that project's count and recent-activity ordering, which live
on the project rows the conversation caches never touch.
The watch moves to its own module so the schedules panel can hand it an
automatic occurrence, and a run announced twice — by the click and by the
poll that then lists it — is watched once.
`policy.spec.ts` proves the tenant rules with no database; `probe.spec.ts`
proves the Mongoose binding reaches the wire scoped. Neither answers what a
second storage engine is actually required to do.
`describeTenantIsolationConformance(harness)` is that answer — 13 behaviours an
engine must exhibit, expressed against a small harness interface rather than
any engine's query surface. Mongoose supplies the first harness; a PostgreSQL
engine satisfies the same suite, which is what turns the seam from
hypothetical into real. Engine-specific cases stay in
`models/plugins/tenantIsolation.spec.ts`.
Writing the contract surfaced one asymmetry worth pinning rather than
rediscovering: an insert naming another tenant is refused in strict mode but
tolerated otherwise, because pre-tenancy backfill has to write rows for a
tenant other than the ambient one. Both halves are now asserted.
Test-only; nothing here is bundled. Originally #15585, reopened directly on
`dev` because GitHub locks the base of a stacked PR and this change is
independent of the runtime fix in #15582.
Tenant isolation is enforced by the storage engine's own binding — Mongoose
middleware today. No amount of reading the binding answers whether it covers
every path, including the ones nobody enumerated, so this checks from below:
`attachTenantProbe` observes the commands that actually reach the wire via the
driver's `commandStarted` monitoring. The check does not depend on the binding
being correct, which is the point of putting it there.
It asks one narrow, decidable question — did the binding's own contribution
arrive, for the tenant active when the command was issued? — rather than trying
to decide tenant-safety for arbitrary filter algebra. Anything it cannot judge
is reported unscoped, so an unfamiliar command shape surfaces as a failure
instead of a silent pass.
`probe.spec.ts` runs the whole enforcement surface through it: 13 query
operations, 3 write paths, and the document-level paths (`doc.save()`,
`doc.updateOne()`, `doc.deleteOne()`, `populate()`) that make schema
middleware irreplaceable by a wrapper around the model's statics.
Test-only; nothing here is bundled.
One gap found and pinned for the next change: `doc.save()` on a persisted
document is filtered on `_id` alone. `estimatedDocumentCount()` is pinned as a
known unscoped side channel (one call site, boot path, metadata only).
* 🖼️ fix: Precache PWA Icons and Let Them Refresh
Production builds set `publicDir: false`, so `client/scripts/post-build.cjs`
copied `public/assets` into `dist/assets` only after `vite build` had finished.
vite-plugin-pwa globs `dist/` from its `closeBundle` hook, inside that build, so
the icon patterns in `workbox.globPatterns` matched zero files and the generated
`sw.js` shipped without a single icon, silently: workbox treats an empty glob as
a no-op. `build:ci`, used by the Playwright lanes, never ran the post-build step
at all and produced a `dist` with no icons or `robots.txt`.
Move the copy into a `copyPublicAssets()` build plugin that runs in
`writeBundle`, which completes for every plugin before `closeBundle`, and drop
the post-build script with its lone `fs-extra` dependency.
Ordering alone was not enough. The plugin defaults `dontCacheBustURLsMatching`
to `/^assets\//`, true only for hashed bundle output. The icons live in
`assets/` under stable filenames, so they entered the manifest with
`revision: null`, permanently immutable, meaning an installed PWA would never
pick up a rebranded icon: the other half of the report. Match Vite's
`[name].[hash].[ext]` shape instead so hashed chunks stay revision-free while
the icons carry content revisions.
Also drop `manifest.webmanifest` from `globPatterns`, since the plugin always
appends it as an additional manifest entry and globbing it duplicated the
precache entry.
A clean `npm run build` now yields 55 precache entries with all five icons
revisioned, against 0 icon entries before.
* 📝 docs: Describe the Full Copy Set in copyPublicAssets
* 🔒 chore: Drop fs-extra From bun.lock