Skip to content

Commit febefea

Browse files
authored
fix(threads): Improve flickering when switching threads (and connecting) (CopilotKit#4154)
This PR includes several fixes that improve the UX of the Langraph + Threads example. Some are part of the example itself, while others are in the core framework (most of which deal with avoiding flickering in the chat window when switching (so, connecting) to threads. Example changes: - package-lock.json included (hence, line count) - restore button + tooltips on archive/restore/delete - segmented Active/All filter - graceful error state if threads don't load - skeleton rows on initial load - stable scrollbar gutter - pre-paint dark-mode class - logo position stable across app/chat modes - drop dynamic-import drawer wrapper that caused null first paint - archived-row dimming via child colors instead of opacity. Framework changes: - Skip copilotkit.connectAgent when CopilotChat lacks a caller-supplied threadId — a locally-minted UUID has no backend record, so /connect would always 404 on the intelligence platform. - Suppress the welcome screen while a connect is in flight and unconditionally when the caller has supplied a threadId (hasExplicitThreadId). Prevents the "How can I help you today?" flash on thread switch. - Gate suggestions on !isConnecting && !isRunning to avoid painting them against a mid-replay message tree. - Defer the isConnecting release by one animation frame so trailing bootstrap renders commit before the flag flips. - Reserve room for the "Powered by CopilotKit" license badge via a new --copilotkit-license-banner-offset CSS var published by the banner on mount; chat input consumes it only when bottom-anchored. - Sort and display threads by lastRunAt (fallback to updatedAt → createdAt) so metadata-only actions like archive/rename don't reshuffle the list. - useThreads waits for runtimeConnectionStatus === Connected before dispatching the store context, eliminating the speculative /threads fetch that fired before /info returned wsUrl. Tests: - CopilotChat.absentThreadConnect: connect is skipped without a threadId, fires when supplied via prop or config. - CopilotChatView.connectingGate: isConnecting suppresses welcome; hasExplicitThreadId suppresses welcome on empty chat. - threads (core): lastRunAt sort fallback ordering. - use-threads: Connecting-state gate defers /threads until Connected. <!-- Thank you for sending the PR! We appreciate you spending the time to work on these changes. Help us understand your motivation by explaining why you decided to make this change. **Please PLEASE reach out to us first before starting any significant work on new or existing features.** By the time you've gotten here, you're looking at creating a pull request so hopefully we're not too late. We love community contributions! That said, we want to make sure we're all on the same page before you start. Investing a lot of time and effort just to find out it doesn't align with the upstream project feels awful, and we don't want that to happen. It also helps to make sure the work you're planning isn't already in progress. As described in our contributing guide, please file an issue first: https://github.com/ag-ui-protocol/ag-ui/issues Or, reach out to us on Discord: https://discord.com/invite/6dffbvGU3D You can learn more about contributing to copilotkit here: https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md Happy contributing! --> ## What does this PR do? (Describe the changes introduced in this PR) ## Related PRs and Issues - (Direct link to related PR or issue, if relevant) - ENT-313 - ENT-317 - ENT-314 ## Checklist - [ ] I have read the [Contribution Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md) - [ ] If the PR changes or adds functionality, I have updated the relevant documentation
2 parents ae20d6b + 8067ecd commit febefea

18 files changed

Lines changed: 14228 additions & 210 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@copilotkit/react-core": minor
3+
"@copilotkit/core": patch
4+
---
5+
6+
fix(threads): stabilize thread-switch UX, skip /connect for absent threads
7+
8+
- Skip `connectAgent` when `CopilotChat` is rendered without a caller-supplied `threadId`. A locally-minted UUID has no backend record, so `/connect` would always 404 on the intelligence platform.
9+
- Expose `lastRunAt` on `Thread` and use it (with fallback to `updatedAt`, then `createdAt`) as the `useThreads` sort key so metadata-only actions like archive/rename no longer reshuffle the list.
10+
- `useThreads` waits for `runtimeConnectionStatus === Connected` before dispatching the store context, eliminating a speculative `/threads` fetch that fired before `/info` returned `wsUrl`.
11+
- Reserve room for the fixed "Powered by CopilotKit" license badge via a new `--copilotkit-license-banner-offset` CSS var published by the banner; chat input consumes it when bottom-anchored so the two no longer overlap.
12+
- Add a new `bottomAnchored` prop on `CopilotChatInput` for callers rendering it as a flex-last-child.
13+
14+
**Behavior change (chat suggestions):** `CopilotChatView` no longer renders suggestions while `isRunning` is true. Previously, suggestions could appear against a streaming-in message tree and visibly reflow as text chunks landed. If you relied on suggestions staying visible during a run, you'll now see them only once the run finalizes (or the thread's initial connect completes).

examples/integrations/langgraph-python-threads/apps/app/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<link rel="icon" type="image/svg+xml" href="/copilotkit-logo-mark.svg" />
77
<title>CopilotKit + LangGraph</title>
8+
<script>
9+
// Apply the theme class before first paint so the page doesn't flash
10+
// white when the OS prefers dark. Mirrors the logic in ThemeProvider;
11+
// its later useEffect is a no-op when the class is already correct.
12+
(function () {
13+
try {
14+
var prefersDark = window.matchMedia(
15+
"(prefers-color-scheme: dark)",
16+
).matches;
17+
document.documentElement.classList.add(
18+
prefersDark ? "dark" : "light",
19+
);
20+
} catch (_) {}
21+
})();
22+
</script>
823
</head>
924
<body>
1025
<div id="root"></div>

examples/integrations/langgraph-python-threads/apps/app/src/components/example-layout/index.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
3737
<div
3838
className={`max-h-full flex flex-col ${
3939
mode === "app"
40-
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
41-
: "flex-1 max-lg:px-4"
40+
? "w-1/3 max-lg:hidden" // Hide on mobile in app mode
41+
: "flex-1"
4242
}`}
4343
>
4444
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4">
@@ -48,7 +48,13 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
4848
className="h-7 dark:invert"
4949
/>
5050
</div>
51-
<div className="flex-1 min-h-0 overflow-y-auto">{chatContent}</div>
51+
<div
52+
className={`flex-1 min-h-0 overflow-y-auto ${
53+
mode === "app" ? "px-6" : "max-lg:px-4"
54+
}`}
55+
>
56+
{chatContent}
57+
</div>
5258
</div>
5359

5460
{/* State Panel */}
Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,4 @@
11
"use client";
22

3-
import { useState, useEffect, type ComponentProps } from "react";
4-
import type ThreadsDrawerComponent from "./threads-drawer";
5-
6-
type Props = ComponentProps<typeof ThreadsDrawerComponent>;
7-
3+
export { default as ThreadsDrawer } from "./threads-drawer";
84
export type { ThreadsDrawerProps } from "./threads-drawer";
9-
10-
export function ThreadsDrawer(props: Props) {
11-
const [Component, setComponent] = useState<
12-
typeof ThreadsDrawerComponent | null
13-
>(null);
14-
15-
useEffect(() => {
16-
import("./threads-drawer").then((m) => setComponent(() => m.default));
17-
}, []);
18-
19-
if (!Component) return null;
20-
return <Component {...props} />;
21-
}

0 commit comments

Comments
 (0)