## Problem
`packages/web-inspector/src/__tests__/launcher-error-signal.spec.ts` is
failing intermittently on `test / unit`, across unrelated branches and
on `main`. It is currently red on the **v1.69.1 release PR**.
```
FAIL src/__tests__/launcher-error-signal.spec.ts >
the whole gesture completes on its own and leaves the resting state behind
AssertionError: expected 'opening' to be 'closed'
```
| When | Branch | Shard | Run |
|---|---|---|---|
| 19:29Z | `release/publish/monorepo/v1.69.1` | Node 24 / React 18 |
[32886136479](https://github.com/CopilotKit/CopilotKit/actions/runs/32886136479)
|
| 14:09Z | `ben1/oss-924-agui-core-058` (#6687) | Node 24 / React 19 |
[32857130830](https://github.com/CopilotKit/CopilotKit/actions/runs/32857130830)
|
| 12:41Z | `lukas/oss-903-presentation-…` | Node 20 / React 18 |
[32848616256](https://github.com/CopilotKit/CopilotKit/actions/runs/32848616256)
|
Neither #6687 (an `@ag-ui/core` version bump) nor the presentation
branch touches gesture timing, and no shard fails consistently — it
follows runner load, not code.
## Cause
Both real-timer tests in this suite asserted a *pre-beat* state 200ms
after breaking the connection:
```ts
const context = await setup({ realTimers: true });
await context.breakConnection();
await context.advance(200);
expect(pillPhase(context.inspector)).toBe("closed"); // ← races the beat
```
Under `realTimers`, `advance(ms)` is a literal `setTimeout(resolve, ms)`
(spec L656–664). The pill's `closed → opening` transition fires at
`ERROR_GESTURE_MS.beat = 400` (`index.ts` L330–339, scheduled at
L18998). So the assertion had a **200ms margin against a 400ms boundary
on a wall clock**. On a loaded runner the 200ms sleep overshoots 400ms,
the beat has already fired, and the phase reads `opening`.
The comment directly above the test already says phase boundaries are
asserted on the fake clock "because real timers would make this suite
slow and flaky" — and then this was a phase-boundary assertion on real
timers.
The same 200-vs-400 race sat in the adjacent test (`pulsing` is true
only for the beat's 400ms), so both are fixed here.
## Fix
Remove the two racy preconditions. Both claims are already pinned
deterministically on the fake clock at spec L411–427, which asserts
`pillPhase === "closed"` **and** `pulsing === true` right after arming,
then walks every phase boundary. These real-timer tests exist only to
show the beat and the gesture run to their end on their own — which the
loops and their closing assertions still prove.
This also matches the idiom the sibling `launcher-signal.spec.ts`
already uses (L667–676): assert at t≈0, then poll for the end.
## Testing
**1. Reproduced the CI failure locally.** Injected a 250ms stall before
the assertion on the unmodified test, simulating a loaded runner (200ms
sleep + 250ms ≈ 450ms > the 400ms beat):
```
FAIL src/__tests__/launcher-error-signal.spec.ts > the whole gesture completes on its own …
AssertionError: expected 'opening' to be 'closed' // Object.is equality
Expected: "closed"
Received: "opening"
❯ src/__tests__/launcher-error-signal.spec.ts:2666:40
```
Byte-for-byte the CI assertion.
**2. The fix survives that same simulation.** With a 700ms stall (well
past the beat) injected into both tests:
```
✓ the beat ends and leaves the resting dot behind 750ms
✓ the whole gesture completes on its own and leaves the resting state behind 3435ms
Tests 2 passed | 88 skipped (90)
```
**3. Mutation-checked that the remaining assertions still have teeth.**
Three separate breaks to `src/index.ts`, each caught:
| Mutation | Result |
|---|---|
| Gesture opens but never closes (drop the `closing` phase +
`endGesture`) | `FAIL … AssertionError: expected <span …> to be null` |
| Pill never opens at all (`openPill` returns early) | `FAIL …
AssertionError: expected false to be true` (`sawOpenPill`) |
| Beat never ends (`beat: 400` → `999_999`) | `FAIL … AssertionError:
expected true to be false` (`pulsing`) |
Source restored afterwards; `git status` confirms this PR touches only
the spec file.
**4. Full `@copilotkit/web-inspector` suite:**
```
Test Files 29 passed (29)
Tests 625 passed (625)
```
**5. Pre-commit gate** (`test-and-check-packages`: test, publint, attw
across 5 projects + 22 dependencies, incl. `@copilotkit/react-core`,
`@copilotkit/angular`, `@copilotkit/runtime`) passed on the committed
tree.
## Note
`main` also has a second, unrelated flake I did not touch here —
`CopilotChatToolRerenders.e2e.test.tsx > should not re-render a
completed tool call when subsequent text is streamed` (`expected 4 to be
3`), which reddened `main` at `0943c519` ([run
32750372113](https://github.com/CopilotKit/CopilotKit/actions/runs/32750372113)),
a runtime `.d.ts` change that touches no react-core chat code. Different
mechanism, worth its own issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What does this PR do?
Design review on the launcher and its notification dot asked for three
things: a milder face than solid black, fewer borders and background
layers, and a subtle shadow in place of the dot's heavy border. This is
all three, plus the removal of six utilities that never had any effect.
Everything here was compared side by side at production size, on a light
*and* a dark host page, before it was chosen. Two of my own first
proposals were dropped after measuring them, both described below.
**Two concerns, three commits.** `4fa38d91f` and `cd711b369` are the
launcher itself — the package change, 2 files. `295f75495` gives the
react-router lab a dark mode, because a dark host page is what this
change has to be judged against and the lab could not produce one. If
you would rather review those separately, say so and I will split them.
## The face
`#181C1F` at 95%, which review asked for.
Worth recording so it does not come up again: the near-black the review
saw was `#010507`, 20.5:1 against white. What shipped yesterday was
already `#1C1F24` at 16.5:1, so this value is a hair *darker* than the
one it replaces (17.2:1) and the difference between them is a ΔE of 2.3,
at the floor of what an eye can separate. It settles the question rather
than changing the look.
## Fewer layers
Six Tailwind utilities on the launcher set properties the unlayered
`css` block sets again — `bg-slate-950/95`, `border-white/20`, `ring-1`,
`ring-white/10` and the two hover variants. Unlayered declarations beat
layered ones regardless of specificity or source order, so none of them
has ever had any effect. Each was the package's only use, so the
checked-in stylesheet drops 980 bytes.
Of the *visible* layers, two went:
**The outer hairline.** The launcher carried two concentric lilac rings:
the border, and a second one 1px outside it as a box-shadow. The outer
one also hardcoded the lilac rather than reading `--cpk-launcher-edge`,
so it silently could not follow the token. It is replaced by a one-pixel
light edge along the top, which is what keeps the face from reading flat
without drawing a frame.
**`backdrop-blur-md`.** It sat behind a 95%-opaque fill and bought close
to nothing, while mounting a permanent blur compositing layer over a
customer's page.
**The border stays, and this is the finding that changed my mind.** I
first proposed removing it too. Against a dark host page the face
measures 1.10:1 (GitHub dark), 1.04:1 (Tailwind slate-900) and 1.22:1
(black) — indistinguishable from the page. The border is the only thing
that gives the launcher an outline there. It is not decoration.
## The dot
The collar was `1.5px`, opaque, zero blur, and 21% of the dot's
footprint. Because the dot's centre sits *on* the rim, its outer half
painted a hard dark crescent onto the **host page** rather than onto the
launcher — which is what read as "heavy". A hairline plus a soft drop
separates it just as well.
The fill becomes a lens lit from the upper left. Both stops are derived
from `--cpk-launcher-signal`, so a new tone needs no new values;
verified for the rose error tone and the violet announcement tone.
**Dropped after looking at it:** a coloured glow around the dot. It was
the obvious reading of "more premium", but the launcher already pulses
in that same colour when a failure is new, and a permanent glow competes
with the thing that is supposed to draw the eye.
**Also dropped:** tinting the border in the signal colour, which was
suggested in review. On a dark page the border is the entire silhouette,
so tinting it recolours the whole launcher for a state that can persist
for hours.
## One non-obvious consequence
Removing the blur removed a side effect nobody had written down:
`backdrop-filter` promotes the element to its own compositing layer.
Without a layer, the hover `scale(1.05)` re-rasterises the mark every
frame and it visibly jitters — geometrically nothing moves, the mark's
centre holds to three decimals, but the vector is re-rendered at
fractional offsets. `will-change: transform` asks for the layer directly
and the jitter is gone. Confirmed by eye on the running demo before this
was chosen.
## Tests
`packages/web-inspector` stays at **28 files / 611 tests**, all passing.
No new tests. The colour tests here are deliberately token-shaped rather
than value-shaped — they assert the custom property and the *sharing* of
one face and one edge between the launcher and its pill, never a hex —
so face and edge values are free to move and this change is exactly the
kind they were written to allow. The one test that constrains it, `"the
pill and the launcher share one surface and one edge"`, still passes.
What is genuinely unguarded, and was before this PR too: the dot's
collar width, the double hairline, and the Tailwind class list.
Asserting rendered geometry would need a browser test runner, which this
package does not have — jsdom computes no layout.
## How to see it
`pnpm --filter react-router-example dev`, then `http://localhost:5173`.
The launcher is top right; `Break runtime` arms the error tone and
`Break run` the announcement one. Hover it to check the mark no longer
jitters.
One thing worth knowing while reviewing: the launcher anchors top-right
and is `position: fixed` on an element mounted directly under `<body>`,
so on this page it sits over the lab's toolbar. Drag it to the lower
right and it is out of the way.
## The lab's dark mode
A dark host page is where the launcher's border earns its place, and the
lab had no way to produce one, so reviewing this change was not possible
without it.
It follows `examples/v2/react/demo` rather than inventing anything: the
host owns a `theme` state, and `CopilotChat` gets `className="dark"` —
which is what makes the package swap its own variable set. The colours
are the demo's by another route; it writes the oklch literals
CopilotKit's variables use, and those are Tailwind's neutral steps
(`neutral-950` is `oklch(0.145 0 0)`, `neutral-50` is `oklch(0.985 0
0)`, `neutral-800` is `oklch(0.269 0 0)`). Measured identical on the
running lab.
`@custom-variant dark (&:is(.dark *))` is needed in the lab's stylesheet
because Tailwind v4 points `dark:` at `prefers-color-scheme` by default,
so the toggle would have lost to the OS. Same declaration the package
uses for its own sheet.
Two details that are decisions rather than oversights. The **error
banner keeps a rose tint** in dark mode instead of going neutral,
because an error banner that looks like every other surface is not an
error banner. And the **toolbar buttons keep a visible on/off contrast**
— active inverts to a light face, inactive sits on `neutral-800` —
because the lab's whole purpose is knowing which failure is currently
armed.
My first attempt stripped every background instead of theming, and that
is worth recording because it looked plausible: the chat bubble, the
send button, the button states and the banner all collapsed into one
flat grey. The chat paints its own surfaces and has to be told what
theme it is in, not undressed.
## A separate bug found on the way
`CopilotKitProvider` documents `inspectorDefaultAnchor` — *"Default
anchor corner for the inspector button and window"* — and it has no
effect. `defaultAnchor` is typed on the React wrapper and forwarded to
the element, but the string `defaultAnchor` does not occur anywhere in
`packages/web-inspector`, so it lands as `defaultanchor="[object
Object]"` and is ignored. The corner stays hardcoded `{ horizontal:
"right", vertical: "top" }` in two places.
Not fixed here, to keep this PR to one concern. It is worth fixing: any
host with a top navigation bar hits exactly this, finds exactly that
prop, and it does nothing.
## Related PRs and Issues
- Follows #6646
The two real-timer tests in the launcher's error-signal suite each asserted a
pre-beat state 200ms after breaking the connection. The beat is 400ms
(ERROR_GESTURE_MS.beat), so both assertions had a 200ms margin against a wall
clock on a shared runner. When the runner is loaded the 200ms sleep overshoots
400ms, the beat has already fired, and the assertion reports the next phase.
Both claims are already pinned deterministically on the fake clock, where every
phase boundary of the gesture is asserted. The real-timer tests exist only to
show the beat and the gesture run to their end on their own, so the racy
preconditions are removed rather than retimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last mix made the HUD arrow a bit too light.
An empty 28px SW resize handle sat on the collapsed sidebar toggle.
In dark mode that handle painted as a black box.
The south and west edges still resize the floating window.
The SE grip is unchanged.
Review asked for fewer borders and boxes, and for the dot's heavy border to
become a subtle shadow.
The launcher had two concentric lilac hairlines: the border, and a second
ring 1px outside it as a box-shadow, which also hardcoded the lilac instead
of reading the edge token. The outer one goes. What replaces it is a
one-pixel light edge along the top, which keeps the face from reading flat
without drawing a frame.
The border itself stays, and it is not decoration: against a dark host page
the face measures 1.10:1 (GitHub dark) and 1.04:1 (Tailwind slate-900), so
without it the launcher has no outline there at all.
`backdrop-blur-md` goes too. It sat behind a 95%-opaque fill and bought
almost nothing, while mounting a permanent blur layer over a customer's
page. It did have one real side effect -- promoting the launcher to its own
compositing layer -- and without that the hover scale re-rasterises the mark
every frame and it visibly jitters. `will-change: transform` asks for the
layer directly, and the jitter is gone.
The dot loses its opaque 1.5px collar. That collar was 21% of the dot's
footprint, and because the dot's centre sits *on* the rim, its outer half
painted a hard dark crescent onto the host page rather than onto the
launcher -- which is what read as "heavy". A hairline plus a soft drop
separates it just as well. The fill becomes a lens lit from the upper left,
both stops derived from the signal colour so a new tone needs no new values.
Considered and dropped: tinting the border in the signal colour. On a dark
page the border is the whole silhouette, so tinting it recolours the entire
launcher for a state that can persist for hours -- and the launcher already
pulses in that colour when the failure is new.
Six Tailwind utilities on the launcher set properties the unlayered `css`
block sets again, and unlayered declarations beat layered ones regardless of
specificity -- so `bg-slate-950/95`, `border-white/20`, `ring-1`,
`ring-white/10` and the two hover variants have never had any effect. Each
was the package's only use, so the checked-in stylesheet drops 939 bytes.
No visual change: verified the three rules are gone from the generated sheet
and the 611 tests still pass.
Kept deliberately: plain `border` (the hand CSS sets only `border-color`),
`rounded-full` (nothing else sets the radius) and the focus-visible trio
(the hand CSS sets only `outline-color`).
The face moves to `#181C1F` at 95%, which review asked for. It is a hair
darker than the `#1C1F24` this replaces -- 17.2:1 against white rather than
16.5:1, a ΔE of 2.3, at the floor of what an eye can separate -- so this
settles the question rather than changing the look.
"no telemetry payload anywhere carries the failure message" serialises the
whole property bag and asserts it does not contain "503". Two of those
properties are the anonymous distinct id -- random hex -- so a three-digit
numeric needle lands inside one roughly once in a few hundred runs. It just
failed a CI job that way, which reads like a privacy breach and is not one.
The ids are excluded by name and asserted to still be strings, rather than
weakening the needles, which are the point of the test. Proven both ways:
with an id seeded to contain "503" the test passes with this change and
fails without it, and it still goes red when a real message is attached to
the payload.
`refocusEventErrorLanding` reads the subject back out of the card's
`data-cpk-event-error` attribute, where the DOM can only offer
`string | undefined`, and handed that to a guard typed for
`LauncherSignalKey`. Narrowing untrusted input is what the guard is for, so
it takes a string; every caller that already holds a key still satisfies it.
Tests do not typecheck, so the suite stayed green and only `check-types`
saw it -- which failed three CI jobs on the same one line.
## What does this PR do?
Lets a developer open a saved Inspector thread in the live official
chat.
- New header action: **View in your app**
- Official React and Vue chat switch to that thread
- A pinned `threadId` does not block the switch
- **Stop viewing** or an app thread change restores the previous thread
- Example threads have no action
- Production builds hide the action
- Same agent only. No matching official chat shows an error in the
Inspector
Core owns a two-way EventClient bridge
(`@tanstack/devtools-event-client`). The root import is a no-op in
production.
Docs: Inspector guide, section **View a thread in your app**.
## Related PRs and Issues
-
https://linear.app/copilotkit/issue/OSS-871/new-features-add-a-new-view-thread-in-your-app-feature
## Checklist
- [x] I have read the Contribution Guide
- [x] If the PR changes or adds functionality, I have updated the
relevant documentation
- [x] Allow edits by maintainers is checked
`handleMenuSelect` applied the event error's landing on every click on Agent
or AG-UI Events. A landing selects the failed agent, clears the event
filters and re-expands the failed row -- help for a reader who came
*because* of that error, vandalism for one who did not.
`lastEventError` outlives being read on purpose, so the how-to-fix card
survives while it is being read. Together that meant: one run error, and
for the rest of the session every return to AG-UI Events silently wiped the
reader's own search filter and re-expanded a row from hours ago. It also
undid the `all-agents` restore eight lines above it, whose comment says it
exists "so the Events tab isn't silently filtered to one agent".
The three arrivals that really are landings keep it -- pressing the
launcher, pressing the card, and an error arriving while its view is
already open. The new test fails with the old call in place, on the value
the reader can see: expected '' to be 'bookFlight'.
- Raw recipe: the Events JSON input now calls requestUpdate, so Run and
Save stop being permanently disabled.
- Tool args recovery: one depth scan replaces the parse-every-prefix loop.
Truncated args from a streaming tool call now fail at once, not after
seconds of blocked main thread.
- Chat bookmark: hidden while the tool arguments are incomplete, so a
partial payload cannot be captured.
- saveEventSnippet: React, Vue, and Angular wrap the body, so a compile or
storage failure is reported instead of becoming an unhandled rejection.
- Vue and Angular now gate the in-chat affordances on a dev build plus
localhost, the same as React. showDevConsole: true on a staging URL no
longer puts a bookmark into a production chat.
The `memory` latch shipped with no test at all, and the one test that
mentions it still explained the old behaviour: "the Memory store is never
even subscribed from here". That is no longer true -- opening the view
does subscribe it -- so the test passed while teaching the wrong thing.
The comment now says what actually holds: without an Intelligence context
the store never fetches, so there is nothing to fail.
Three tests: a refused load names itself and lands on Learning, a refused
load arms nothing while the view has never been opened, and a resolved one
clears itself. The middle one is the important one -- it is the constraint
that makes `memory` an unread event rather than a state the launcher could
mirror from a cold start.
The harness needed two things. `intelligence` is now settable, because the
view is a locked teaser without it and the latch is unreachable. And the
memory store stub replaces its state rather than mutating it: the store's
selectors memoize on the state they were handed, so an in-place edit
reports the stale value forever and looks exactly like a signal that never
fires.
Also hoists the selector-escape pattern so `no-control-regex` can be
suppressed on it with a reason -- the control range is the point, since an
attribute selector escapes those too. Lint returns to its 5359 baseline.
Arming used to wait 2000ms so a blip produced neither dot nor beat. The
window is gone and that is deliberate, but nothing said so, and the tests
now assert immediate arming as the contract -- so the next reader would
find no trace of the trade-off, only the behaviour.
What the note holds: `threads` can flap and plays a whole gesture for a
blip, bounded by the single pending-beat slot to one gesture per gesture
length; `connection` cannot flap on its own because nothing retries the
handshake; and the `connecting` branch already covers the retry case if a
fix for the mid-session gap adds polling. Mirrored on OSS-904, which is
where such a fix would land.
The landing card ended in "The failed run event is highlighted below."
unconditionally, but the error carries no guarantee that such an item
exists. A code that reaches `run` through the catch-all without a run of
its own -- a locked thread, an agent that was never registered -- lands on
an empty AG-UI Events with the card still pointing at nothing, and
`applyEventErrorLanding` bails out silently rather than telling it. Same
for a tool error that arrives without a call id.
Split the copy by what it can promise: `advice` is about the reader's next
move and always holds, `highlight` is a claim about this view and renders
only once the item is there. Sending someone to look for something absent
is worse than saying nothing.
The mapping itself is unchanged. Whether a locked thread should be called
a failed run at all is a separate question for its author.
The thread-list-error route asserts that the first open lands on Threads
rather than Home. That only holds once the list request has been refused,
and the request is still in flight at that point: the helper waits two
microtask turns and a render, never the fetch. On a loaded machine the
fetch loses the race, the launcher carries no signal yet, and the open
lands on Home.
Green locally, red on all six CI matrix combinations. Reproduced here by
delaying the list fetch, which fails the assertion at 300ms and passes it
with this change at 1200ms.
Show agent, tool, and error on Inspector banners. Click the launcher to open the failed tool call or RUN_ERROR. Shorten the error pill wait to 400ms. Use dark gray titles on the rose tool-error card so the name stays readable.
Five changes from looking at rendered variants side by side, none of them
behavioural.
The reveal animates a rounded clip. Without a round component the
revealing edge is a straight line sweeping sideways, which reads as a
wipe; with it the edge is the capsule's own cap travelling outward.
The text side of the padding is derived from the capsule's radius rather
than being a literal. Padding is measured from the bounding box, but the
first half-height of that side is the rounded cap, so a bare 14px left
the words sitting inside the curve — and the launcher size is itself a
clamp on the viewport, so no literal could have been right at every size.
The label is two lines now, a heading and a subline, at 12px and 10.5px.
It stays exactly as tall as the launcher; there was room above and below.
The pill takes pointer events while it is on screen, so a click opens the
Inspector as pressing the launcher does. That makes the subline's
instruction honest. It is deliberately not focusable: the launcher is
already a focusable control for the same action, and a second tab stop
for one action would be a regression.
The launcher's surface and edge are now two custom properties declared
once on the wrapper and resolved by both the button and the pill, so the
two cannot drift apart. The pill's own red-tinted border is gone — it read
as a second object rather than as the launcher opening. The surface moves
from near-black to a dark grey, softening the edge against a white page
from 20.5:1 to 16.5:1 while staying far above any legibility threshold.
Refs OSS-903
Once per outage the launcher beats, then opens sideways into a short pill
carrying the failure's name, holds long enough to read, and closes back to
the plain mark. Nothing stays behind: the dot keeps the state, the pill
carries only the moment.
The ticket this belongs to started with a user who could not tell what the
button was. A dot says something is wrong without saying who is asking, so
the pill names both the problem and the control raising it.
Errors only. The announcement feed's preview text is 54 characters against
a 36px launcher, so a pill carrying it would be ten times the width of the
control it grows from, sized by a feed we do not control. The label is read
from the signal rather than keyed off the tone, so a third signal can carry
one by declaring it.
The reveal animates a rectangular clip. Animating width would recompute
layout on every frame of someone else's page; scaling horizontally squashes
the mark itself, not merely the rounded ends. The contract is therefore
restated rather than broken: the rule was never "opacity and transform", it
is that nothing may force a layout per frame.
Direction comes from available room, because the launcher can be dragged to
within a margin of the left edge and that position persists. Left if it
fits, otherwise right, otherwise no pill at all -- the dot and the beat
still fire, so only the label is lost.
Recovery says nothing. The dot going out is already the message, and
announcing it would double the gestures across the break-and-fix cycle that
debugging consists of.
The whole gesture holds the single pending-beat slot, so an announcement
beat waits rather than cutting into it. Reduced motion gets the same words
and the same reading time without the movement. A polite live region speaks
the failure once, so the pill is not a sighted-only feature.
Refs OSS-903