## CopilotDrawer — SDK polish + thread-management fixes
Polish and fixes on top of the shipped `<copilotkit-drawer>`
([#5701](https://github.com/CopilotKit/CopilotKit/pull/5701)), surfaced
by real de-fork pilots run against a locally-built SDK + the hosted
Intelligence runtime: **langgraph-js** (rich canvas / inline
`CopilotChat` / dark theme) and **mastra** (the proverbs CoAgents demo /
`CopilotSidebar` / light theme / controlled→uncontrolled provider).
Spans **`@copilotkit/web-components`** and **`@copilotkit/react-core`**
— no example/de-fork changes here (those wait on a release that
publishes web-components + a `react-core` bump that includes
`CopilotDrawer`). Captured as Phase-5 acceptance requirements in the
[design doc](https://app.notion.com/p/3883aa3818528190b5d1f9b6ba26dca0).
### `react-core` — thread management
- **Thread switching works under `<CopilotKit>`.**
`CopilotChatConfigurationProvider` treated any `threadId` prop as
controlled, so the auto-minted, non-explicit threadId that
`<CopilotKit>`'s v1 bridge seeds blocked the drawer's imperative
`setActiveThreadId` / `startNewThread` — thread switching and "+ New"
were dead in every app wrapped in `<CopilotKit>`. A non-explicit
(`hasExplicitThreadId={false}`) threadId is now overridable; a genuine
caller-supplied threadId stays controlled.
- **"+ New" resets the chat.** `CopilotChat` clears messages when
switching to a fresh non-explicit thread so the welcome screen shows
instead of the prior thread's messages — including when a still-loading
`/connect` is superseded by the switch (the aborted connect's stale
snapshot is dropped instead of repopulating the view).
- **No upsell flash/stick.** `CopilotDrawer` treats a pending (`null`)
license status as loading, not unlicensed, so the upgrade upsell no
longer flashes (or sticks) before the runtime reports the license.
- **In-header drawer launcher is mobile-only.**
`CopilotModalHeader.DrawerLauncher` toggles `drawerOpen`, which only
drives the off-canvas *mobile* drawer; on desktop the drawer is a
persistent in-flow panel that ignores `open`, so the launcher was a dead
no-op there. It now renders only at ≤767px. On desktop the chat's own
open/close (the toggle FAB + the header close, both lucide `X`) is the
consistent pair.
### `web-components` — theming, launcher, icons, tooltips
- **Host-theme inheritance (incl. dark mode).** Each token now resolves
`--cpk-drawer-* override → host theme var
(`--background`/`--card`/`--foreground`/`--border`/…) → built-in light
default`. The drawer follows the host app's light/dark theme by
inheritance, instead of baking light-only values (it showed a light
drawer on a dark app). Custom properties aren't reset by the `:host {
all: initial }` guard, so the standalone fallback still holds.
- **Row actions are icon buttons with instant, on-brand tooltips.**
Archive / Unarchive / Delete render inline lucide-style SVGs
(`currentColor`). Native `title` is replaced by an instant CSS tooltip
(matches the React components' Radix `delayDuration: 0`; `title`'s
show-delay is browser-fixed at ~1.5s), styled to match the standard
CopilotKit tooltip — a primary-colored bubble with an arrow, not a
surface/bordered box — and positioned to the side so the list's
scroll-overflow never clips it. The tooltip is suppressed while the
delete-confirm dialog is open (the clicked trash button keeps
`:focus-visible` otherwise, leaving its "Delete" tooltip floating over
the dialog).
- **Clipped thread names get a tooltip.** A truncated name exposes its
full text via the same instant primary-bubble tooltip as the row actions
(not the native `title`), shown only when actually clipped. The name
text moved to an inner span (so the outer can host the un-clipped
bubble) and the hovered row is z-lifted (each row is its own stacking
context from the entry-animation transform, which otherwise painted the
bubble under later rows).
- **Archived rows are muted**, no longer struck through.
- **Refetch preserves the list.** A refetch (e.g. Active↔All toggle)
keeps the known threads visible; the full loading state only renders on
the initial empty fetch.
- **Empty `footer` region hides** (it rendered as a stray bordered box
at the bottom). Driven by a `slotchange` listener, mirroring the
`memories` region.
- **Self-owned mobile launcher.** When the drawer is a closed off-canvas
mobile modal it renders its own floating "open threads" launcher, so
it's always openable without the host wiring a header button. The icon
is swappable via a `launcher-icon` **slot**, position is themeable via
`--cpk-drawer-launcher-top` / `--cpk-drawer-launcher-left` (so a host
can center it on its own header controls), and it exposes
`part="launcher"`. The richer in-header
`CopilotModalHeader.DrawerLauncher` remains an optional integration.
### Tests
- **react-core**: thread-control + non-explicit-override cases on
`CopilotChatConfigurationProvider`, the "+ New" message reset on
`CopilotChat`, license-pending gating on `CopilotDrawer`, and the
mobile-only / desktop-hidden launcher on `CopilotModalHeader`. New
regression tests verified red-green; chat + provider suites green, `tsc
--noEmit` clean, build green. (The superseded-connect *race* guard is
covered by live verification + its synchronous sibling test — an
async-timing unit test would be flaky, which the repo treats as a bug.)
- **web-components**: element tests for the icon tooltips +
confirm-dialog suppression, the clipped-name tooltip (`data-tooltip` +
placeholder), archived styling, refetch-preserves-list, footer
hide-when-empty, and mobile launcher render/open + desktop-absent.
Drawer suite green, `tsc --noEmit` clean, build + `es-check` green under
`CI=true`. (Truncation detection + tooltip stacking are layout/CSS —
verified live, not in jsdom.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
_syncNameClipping toggled name-clipped on .row-name, but the stacking fix in
styles.ts targets .row.name-clipped:hover — a different element — so the z-index
lift never matched and clipped-name tooltips still painted under later rows
(each row is its own transform stacking context). Stamp the flag on the owning
.row too; the tooltip bubble stays scoped to .row-name:hover so a row-action
hover never surfaces it. Adds a red-green contract test asserting the class
lands on both the row-name and the row.
Addresses MikeRyanDev review on #5707.
A long thread name is clipped with an ellipsis; expose the full text via the
same instant primary-bubble tooltip as the row actions (not the native `title`),
shown only when the name is actually truncated. The name text moved to an inner
span so the outer `.row-name` can host the un-clipped bubble, and the hovered
row is z-lifted so the bubble isn't painted under later rows (each row is its
own stacking context from the entry-animation transform).
The in-header thread-drawer launcher toggles `drawerOpen`, which only drives the
off-canvas MOBILE drawer; on desktop the drawer is a persistent in-flow panel
that ignores `open`, so the launcher was a dead no-op there (it appeared to do
nothing on click). Gate it on a reactive mobile viewport check so it renders
only at ≤767px. On desktop the chat's own open/close — the toggle FAB + the
header close (both lucide X) — is the consistent pair; nothing masquerades as a
chat control.
Row-action tooltips used a surface background + border, which read as a button.
Restyle to the standard CopilotKit tooltip: primary bubble, primary-foreground
text, an arrow, no border. Also suppress the tooltip while the delete-confirm
dialog is open — the clicked trash button keeps :focus-visible, which otherwise
left its "Delete" tooltip floating over the dialog.
CopilotChatConfigurationProvider treated any `threadId` prop as controlled, so
the auto-minted non-explicit threadId that <CopilotKit>'s v1 bridge seeds
blocked the drawer's imperative setActiveThreadId/startNewThread — thread
switching and "+ New" were dead in every app wrapped in <CopilotKit>. A
non-explicit (`hasExplicitThreadId={false}`) threadId is now overridable; a
genuine caller-supplied threadId stays controlled.
Also, surfaced during the langgraph-js de-fork validation:
- CopilotChat clears messages when switching to a fresh non-explicit thread
("+ New") so the welcome screen shows instead of the prior thread — including
when a still-loading /connect is superseded by the switch.
- CopilotDrawer treats a pending (null) license status as loading, not
unlicensed, so the upgrade upsell no longer flashes or sticks before the
runtime reports the license.
Polish on top of the shipped <copilotkit-drawer> (#5701), surfaced by the first
real de-fork pilot (langgraph-js):
- Theming follows the host app's light/dark theme: each token resolves
override -> host theme var (--background/--card/...) -> built-in light default,
instead of baking light-only values (drawer was light on a dark app).
- Row actions are icon buttons (inline lucide-style SVG, currentColor) with
instant tooltips. Native title is replaced by a CSS tooltip (matches the
react components' delayDuration:0; title's show-delay is browser-fixed) and is
positioned to the side so the list's scroll-overflow never clips it.
- Archived rows are muted, no longer struck through.
- A refetch (e.g. Active<->All filter toggle) keeps the known list visible;
the full loading state shows only on the initial empty fetch.
- The reserved footer region hides when nothing is slotted into it (it rendered
as an empty box at the bottom); mirrors the memories region.
- Mobile gets a self-owned floating launcher so the drawer is always openable
with no host header wiring. Its icon is swappable via a launcher-icon slot and
its position via --cpk-drawer-launcher-top/left (host can center it on its own
header controls); exposes part="launcher".
Adds element tests for icons+tooltips, archived styling, refetch-preserves-list,
footer hide-when-empty, and the mobile launcher (render + open + desktop-absent).
The StateStore interface and the in-memory MemoryStore default remain;
durable backends can be reintroduced as a follow-up. Both adapter packages
were merged in #5613 but never published to npm, so removal is a clean
delete with no consumer impact.
- Delete packages/bot-store-redis and packages/bot-store-postgres.
- Revert the bot release scope and drift guard to bot + bot-ui.
- Strip the Redis dep, demo:restart script, restart demo, docker-compose,
and REDIS_URL env from examples/slack.
- Rewrite the bot persistence/transcripts docs around "MemoryStore default
+ implement the StateStore interface yourself for durability".
Adds a monorepo-invariant test that scans sibling bot-* adapter packages for their declared `platform` literal and fails if any is missing from normalizePlatform's allow-list. It immediately caught the new bot-teams adapter (platform "teams") that landed on main — now added to the allow-list.
Address PR review: bound the free-form adapter platform label to slack|discord|telegram|whatsapp|custom (no tenant/project leakage, capped cardinality); emit oss.bot.agent_run only after the transcript-append + renderer.finish() steps succeed, with a finalize stage on oss.bot.agent_run_failed for late failures.
createBot emits configured + started/start_failed; Thread emits agent_run/agent_run_failed around runAgentLoop. Zero new env vars. Includes mocked-wiring unit tests and a real-BotTelemetry e2e test.
BotTelemetry posts 5 oss.bot.* events at 100% via @copilotkit/shared's lambdaClient. Anonymous (no telemetry_id/license/key), opt-out via COPILOTKIT_TELEMETRY_DISABLED/DO_NOT_TRACK, suppressed under test. 3-tier anonymous_id (durable store -> project cache file -> per-process UUID). errorClass() maps errors to a bounded category, never a raw message.
Add an optional onAction interceptor to createA2UIMessageRenderer so apps
can handle A2UI actions client-side (e.g. navigate) instead of forwarding
every action to the agent.
- onAction(action, forward) runs before forwarding. Return null to handle
client-side and stop forwarding, return a modified action to forward it,
or return undefined to forward unchanged.
- Threaded from createA2UIMessageRenderer options through ReactSurfaceHost.
- Extracted runA2UIAction helper for deterministic unit testing.
- Default behavior is unchanged when onAction is not supplied.
The reaction handler now receives the conversation `thread` and an
update-capable `messageRef` for the reacted message — the same surface an
`onClick` gets via `ctx.thread`/`ctx.message.ref`. A reaction can now post new
UI (`thread.post`), swap the message in place (`thread.update(messageRef, …)`),
run the agent, or block on a human choice (`thread.awaitChoice`, HITL).
- bot-ui: `MessageReaction` gains `thread` and `messageRef`.
- platform-adapter: `IncomingReaction` gains an optional adapter-provided
`messageRef` (engine falls back to `{ id: messageId }`).
- create-bot: threads `thread` + `messageRef` into both the global
`ReactionEvent` and the per-message handler.
- Slack/Discord/Telegram reaction decoders emit a platform-specific,
update-capable `messageRef` (channel+ts / channelId+id / chatId+messageId).
Type the bot UI surface and broaden the cross-platform component vocabulary,
adding only capabilities that more than one adapter can express.
- bot-ui: type the `Thread` interface's `ui` params (`post`/`update`/
`awaitChoice`/`postEphemeral`) as `Renderable` instead of `unknown`, so JSX
is checked at the call site and the `{ raw }` escape hatch is explicit.
- `<Message onReaction>`: per-message reaction callback `(emoji, { added, user,
rawEmoji, messageId })`. Stripped from the IR before it reaches the adapter,
routed from reaction ingress by message id. Durable on the same terms as a
component `onClick` — a `{ component, props }` snapshot is persisted and the
component is re-rendered to re-derive the handler after a restart; inline
handlers route in-process only.
- `Button.url` link buttons — Slack, Discord, Teams, Telegram.
- `Field.label` — typed (Discord and Telegram already consumed it untyped);
newly rendered on Slack.
- `Select.multi` multi-select — Slack (`multi_static_select` in an input block
+ `selected_options` decode), Discord (min/max values + `component` bounds
decode), Teams (`isMultiSelect`); Telegram/WhatsApp degrade to single-select.
Slack-only capabilities (Button.confirm, Image.title, Input label/initialValue/
required, Select.initialValue) were intentionally left out.
## What
Bake a heap ceiling into the `@copilotkit/core` build script:
```diff
-"build": "tsdown",
+"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsdown",
```
(`cross-env` is already used elsewhere in the repo; added here as a core
devDependency.)
## Why
The core build (tsdown + dts generation) has a ~3.45GB working set and
OOMs under `nx run-many`, where it competes with sibling builds and GC
falls behind.
This mostly bites **local development**. Agents (and humans) routinely
have to prefix commits with `NODE_OPTIONS=--max-old-space-size=8192`
just to get the build through pre-commit hooks. CI already sets this
flag at the workflow level, so baking it into the build script means
every invocation path gets the same headroom without anyone remembering
to add it: local lefthook hooks, `nx run-many`, a direct `pnpm build`,
CI, and Windows (hence `cross-env`).
Scoped to core only, since it's the single package that OOMs.
The memory store's socket effect created the `user_meta:memories:<code>`
channel and observed its events, but never subscribed the join-outcome
stream — and `ɵphoenixChannel$` only calls `channel.join()` when that stream
is subscribed. So the socket connected but no `phx_join` was ever sent: the
gateway saw the connection, the channel was never joined, and saved memories
never pushed to the client (REST snapshot worked; live deltas silently never
arrived). The thread store joins because its merged `joinOutcome$` subscribes
the stream.
Merge `ɵjoinPhoenixChannel$(channel$)` into the socket effect's output so the
join actually fires; swallow a failed/timed-out join so a transient rejection
doesn't tear down the metadata stream (Phoenix re-attempts on reconnect). Add
a `joinCount` spy to MockChannel and assert the realtime test actually joins —
this regression passed before because the mock delivered events without a join.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The @copilotkit/core build (tsdown with dts generation) has a working set
of about 3.45GB. Node's default old-space ceiling is roughly 4GB and CI pins
NODE_OPTIONS to 4096, leaving almost no headroom. Under nx run-many the build
competes for CPU with sibling builds, GC falls behind, and the process tips
over the heap limit. nx flags it as a flaky task and pre-commit/CI builds fail
intermittently with a V8 heap OOM.
Bake the ceiling into the core build script with cross-env so every invocation
path gets consistent headroom: local lefthook hooks, nx run-many, CI, and
direct pnpm build, on every platform including Windows. 8192 matches the value
already used by the e2e workflow and gives roughly 2x headroom over the working
set.
Scoped to core only; it is the single package that OOMs.
Adds the @copilotkit/bot-teams package: a PlatformAdapter that bridges
the CopilotKit runtime to Microsoft Teams. Renders agent output as
Adaptive Cards (including native <Chart> components via bot-ui), streams
replies with a typing-indicator heartbeat, supports human-in-the-loop
confirmations, and handles inbound files via the Graph API with channel
file support.
ɵsubscribeToMemories mirrored ɵsubscribeToThreads and sent userId in the
body, but the platform's memory routes resolve the app user from the
x-cpki-user-id header (like listMemories/createMemory), not the body — so
POST /api/memories/subscribe returned 401 AUTH_UNAUTHENTICATED. Send the
user via the header instead; the body is now empty.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The memory store client POSTs /memories/subscribe to mint realtime join
credentials, but the runtime router had no such route — the path fell
through to /memories/:id (memories/mutate), so a POST returned 405 Method
Not Allowed. Threads had the full chain (route → handleSubscribeToThreads →
platform); memory was missing the runtime proxy between the client and the
platform's POST /api/memories/subscribe.
Add `memories/subscribe` to the route union, match it before /memories/:id
(and exclude "subscribe" from the :id rule, mirroring threads), add
handleSubscribeToMemories returning { joinToken, joinCode } (memory delivers
the join code here, unlike threads where it rides the thread-list response),
and ɵsubscribeToMemories on the platform client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mobile drawer is a modal dialog, but the Tab trap was bound to .root only,
while the backdrop button renders as a sibling OUTSIDE .root — so Tab from the
backdrop (or any out-of-root node) could escape the modal. Move the trap to the
host keydown listener (keydown is composed, so it catches the backdrop too) and
include the backdrop in the focusable cycle; keep initial focus on the first
real control. Adds a regression test for backdrop/outside-root Tab containment.
The UMD bundle inlines lit for a self-contained CDN <script>; inlining lit also
pulls in its runtime deps (@lit/reactive-element, lit-html, lit-element,
@lit-labs/ssr-dom-shim). tsdown flags that transitive bundling as 'unintended'
and promotes the warning to a fatal error under CI, breaking
@copilotkit/web-components:build (and every dependent). Declare all lit packages
in noExternal and set inlineOnly: false to mark the bundling intentional.
Angular signal wrapper over the core thread store: reactive runtime context
(re-dispatches when wsUrl/threadEndpoints arrive), enabled-gated single-slot
registration, bound result methods, and synthesized error/loading parity with
react-core.
Add <CopilotDrawer> (interops with the shadow-DOM element, two-pronged license
gate, scoped chat-input focus return, registration-gated header launcher) and
extend CopilotChatConfigurationProvider with drawerOpen + mobile mutual
exclusion + a non-explicit active-thread setter so a bare drawer connects to the
picked thread and resets on New with no host wiring. useThreads gains an
{enabled} gate and a list-only error channel.
Shadow-DOM Lit element that renders the threads drawer: self-contained styles
with build-time token sync from react-core's theme, slot projection for custom
rows, license/upsell gating, filtering, and a mobile modal. View-state lives on
the element; domain state stays with the host. Multi-format build (ESM/CJS
externalize lit as a peer dep; UMD inlines lit so the CDN <script> path is
self-contained).
Add the thread store (optimistic add/rename/archive/delete with delete
rollback-on-reject, session-guarded results, startNewThread/refetchThreads,
getServerState/getServerSnapshot, per-store memoized selectors, and non-fatal
realtime diagnostics for channel-join and metadata-credential failures) and
register the "threads" feature so the SDK can gate thread management.
## What
Replaces the homemade animated `⏳` "thinking"
placeholder (a posted message whose dots cycled via `setInterval`) with
Slack's **native** `assistant.threads.setStatus` "is thinking…"
indicator — and generalizes it from assistant-pane-only to **every
thread-anchored reply**: channel @-mentions, channel threads, DMs, and
the pane.
## Why
The hourglass was an extra post-then-delete message that looked nothing
like the native loading state other Slack agents show. Slack has since
relaxed `assistant.threads.setStatus` to accept the ordinary
**`chat:write`** scope (not just `assistant:write`), specifically so
channel-based apps can show AI loading states in channels and DMs
([docs](https://docs.slack.dev/reference/methods/assistant.threads.setStatus/)).
The adapter already used `setStatus` for the pane — this just extends it
everywhere and deletes the placeholder.
## How
- **`event-renderer.ts`** — delete the hourglass machinery
(`startThinking`/`claimThinking`/`clearThinking`/`setInterval`);
generalize `setPaneStatus`/`clearPaneStatus` →
`setStatus`/`clearStatus`. New `status?: { threadTs, isPane, config }`
arg: `statusMode` drives the thinking indicator; `isPane` only selects
tool-progress surface (pane composer status vs `task_update` timeline /
`🔧` rows — both unchanged).
- **`adapter.ts`** — `createRunRenderer` builds `status` for any target
with a thread anchor (`threadTs ?? statusTs`); `assistant: false` opts
out everywhere.
- **`types.ts` / `slack-listener.ts`** — flat DMs (no `thread_ts`) carry
the inbound message `ts` as the status anchor; replies still post flat.
- **README** — document the broadened status + the `chat:write`
relaxation.
## Testing
- `nx run-many -t test build` for `bot-slack` — **248 tests pass**,
build clean, oxlint 0 errors, oxfmt clean.
- New tests cover non-pane channel-thread status, the `🔧` tool
path staying on non-pane, and the DM `statusTs` anchor.
- Net **−149 lines**.
> Note: the DM path anchors `setStatus` to a non-assistant DM message ts
— the one piece only verifiable against the live Slack API. Channel
@-mentions already carry a real `thread_ts`.
ɵMemoryStore exposes addMemory(input: NewMemory) and
updateMemory(id, changes: MemoryChanges), but those input types were
internal — a thin framework binding (useMemories / injectMemories) had
no way to name them when wrapping the store. Export them alongside the
existing ɵMemory / ɵMemoryKind / ɵMemoryScope.
Replace the homemade animated ⏳ placeholder (a posted
message with setInterval-cycled dots) with Slack's native
assistant.threads.setStatus "is thinking..." indicator on every thread-anchored
reply -- channel @-mentions, channel threads, DMs, and the assistant pane -- not
just the pane. Slack now accepts setStatus under chat:write (not only
assistant:write), so it works for channel-based apps. Flat DMs carry the inbound
message ts as the status anchor. assistant:false opts out everywhere.
Memory handlers now forward client-actionable 4xx platform statuses
verbatim (404 not-found, 409 conflict, 422 unprocessable) so a useMemories
consumer can distinguish those from a server error, and map a platform 5xx
(or malformed status) to 502 rather than collapsing everything to 500 — the
runtime is healthy, its dependency failed. This also avoids a Response
RangeError on an out-of-range status.
parseMemoryBody and the platform client's createMemory/updateMemory now
treat scope as optional, deferring the default to the platform.
refresh() now returns a promise that rejects when the re-pull fails
(listFailed) or the store is stopped mid-flight, instead of resolving
silently as if it succeeded — symmetric with the mutation methods. The
error still lands in the error selector for declarative consumers.
toMutationBody no longer defaults scope to "user"; it forwards scope only
when the caller supplies it, so the platform owns the single default.
Adds POST /memories (create), PATCH /memories/:id (supersede), and
DELETE /memories/:id (retire) to the intelligence runtime, mirroring the GET
read route: CopilotKitIntelligence.{createMemory,updateMemory,removeMemory}
proxy the platform's /api/memories with the identifyUser-resolved user via the
x-cpki-user-id header. Completes the REST surface the client memory store's
addMemory/updateMemory/removeMemory call.
refresh() now resolves once the re-pulled snapshot settles (listSucceeded/
listFailed) for the current session, or immediately when no context is set —
so a useMemories-style binding can expose refresh(): Promise<void>. Mirrors
trackMutation's completion handling.