Skip to content

Commit 4264e02

Browse files
committed
fix(conversations): stop welcome controls lingering after a tab switch
Inactive conversation tabs stay mounted and hide with visibility:hidden. Tailwind v4's transition-all transitions visibility too, so welcome controls (agent-selector pills, quick-action tabs, composer buttons) kept animating their inherited visibility and stayed painted 150-300ms over the newly-active conversation. Suppress transitions on the hidden tab subtree so visibility snaps immediately, and derive the tab body's active state directly from the active tab id.
1 parent 4941c19 commit 4264e02

3 files changed

Lines changed: 41 additions & 18 deletions

File tree

src/app/globals.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,21 @@
15061506
}
15071507
}
15081508

1509+
/* Keep-alive conversation tabs (see ConversationDetailPanel) hide the inactive
1510+
tab with `visibility: hidden` (Tailwind `invisible`) while staying mounted so
1511+
the active tab can reuse their layout. Tailwind v4's `transition-all` resolves
1512+
to `transition-property: all`, which includes `visibility`; a visible→hidden
1513+
`visibility` transition keeps the element painted for its whole duration. As
1514+
`visibility` inherits, every descendant carrying `transition-all` (AgentSelector
1515+
pills, QuickActions tabs, composer buttons) would otherwise linger 150–300ms as
1516+
pointer-events-none ghosts over the newly-active conversation. Nothing in a
1517+
hidden tab is visible, so drop all transitions in the subtree and let
1518+
visibility snap immediately. */
1519+
.conversation-tab-hidden,
1520+
.conversation-tab-hidden * {
1521+
transition-property: none !important;
1522+
}
1523+
15091524
.select-none {
15101525
-webkit-user-select: none;
15111526
-moz-user-select: none;

src/components/conversations/conversation-detail-panel-layout.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const conversationShellSource = readFileSync(
2424
resolve(process.cwd(), "src/components/chat/conversation-shell.tsx"),
2525
"utf8"
2626
)
27+
const globalsCssSource = readFileSync(
28+
resolve(process.cwd(), "src/app/globals.css"),
29+
"utf8"
30+
)
2731

2832
describe("ConversationDetailPanel new conversation layout", () => {
2933
it("keeps the new-conversation input in the welcome panel with the original scroll layout", () => {
@@ -52,6 +56,25 @@ describe("ConversationDetailPanel new conversation layout", () => {
5256
expect(welcomeBranch).toContain("tall")
5357
})
5458

59+
it("snaps the hidden keep-alive tab so `transition-all` descendants don't ghost", () => {
60+
// Inactive tabs stay mounted and hide with `visibility: hidden` (`invisible`).
61+
// In Tailwind v4 `transition-all` transitions `visibility` too, so welcome
62+
// controls (agent pills, quick-action tabs, composer buttons) would linger
63+
// 150–300ms as ghosts over the newly-active conversation. The wrapper must
64+
// carry `conversation-tab-hidden` next to `invisible`, and globals.css must
65+
// drop transitions for that subtree so visibility snaps. Both halves are
66+
// required — assert they stay coupled.
67+
expect(source).toContain(
68+
'"conversation-tab-hidden absolute inset-0 invisible pointer-events-none"'
69+
)
70+
expect(globalsCssSource).toContain(".conversation-tab-hidden *")
71+
const rule = globalsCssSource.slice(
72+
globalsCssSource.indexOf(".conversation-tab-hidden,"),
73+
globalsCssSource.indexOf(".conversation-tab-hidden,") + 200
74+
)
75+
expect(rule).toContain("transition-property: none !important")
76+
})
77+
5578
it("does not render a decorative welcome backdrop", () => {
5679
expect(welcomeHeroSource).not.toContain("export function WelcomeBackdrop")
5780
expect(welcomeHeroSource).not.toContain("bg-gradient-to-r")

src/components/conversations/conversation-detail-panel.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import {
44
memo,
55
useCallback,
6-
useDeferredValue,
76
useEffect,
87
useMemo,
98
useRef,
@@ -1718,15 +1717,6 @@ export function ConversationDetailPanel() {
17181717
const allFolders = useAppWorkspaceStore((s) => s.allFolders)
17191718
const tabs = useTabStore((s) => s.tabs)
17201719
const activeTabId = useTabStore((s) => s.activeTabId)
1721-
// Content-priority active id. The per-tab wrapper's show/hide reads the urgent
1722-
// `activeTabId`, so the outgoing tab (notably the new-session welcome page)
1723-
// hides in the same frame as the click; the heavy per-tab subtree only flips
1724-
// its `isActive`/`showActiveFlow` when this deferred value catches up — a
1725-
// non-blocking transition a frame later. Previously both tabs' large memoized
1726-
// bodies re-rendered in the same synchronous commit that hid the old tab, so
1727-
// the browser couldn't paint the hidden state until that work finished and the
1728-
// welcome components appeared to linger before disappearing.
1729-
const deferredActiveTabId = useDeferredValue(activeTabId)
17301720
const isTileMode = useTabStore((s) => s.isTileMode)
17311721
const { openNewConversationTab, closeTab, switchTab, onPreviewTabReplaced } =
17321722
useTabActions()
@@ -2035,21 +2025,16 @@ export function ConversationDetailPanel() {
20352025
}
20362026

20372027
const tabElements = tabs.map((tab, index) => {
2038-
// `active` drives the urgent show/hide (below); `contentActive` (deferred)
2039-
// drives the tab body's active-only work, so a switch paints the visibility
2040-
// change before re-rendering the heavy subtrees. They agree once the
2041-
// deferred value settles a frame later.
20422028
const active = tab.id === activeTabId
2043-
const contentActive = tab.id === deferredActiveTabId
20442029
const folderPath = allFolders.find((f) => f.id === tab.folderId)?.path
20452030
const view = (
20462031
<ConversationTabView
20472032
tabId={tab.id}
20482033
conversationId={tab.conversationId}
20492034
agentType={tab.agentType}
20502035
workingDir={tab.workingDir ?? folderPath}
2051-
isActive={contentActive}
2052-
showActiveFlow={canTile && contentActive}
2036+
isActive={active}
2037+
showActiveFlow={canTile && active}
20532038
reloadSignal={reloadByTabId[tab.id] ?? 0}
20542039
/>
20552040
)
@@ -2071,7 +2056,7 @@ export function ConversationDetailPanel() {
20712056
)
20722057
: active
20732058
? "h-full"
2074-
: "absolute inset-0 invisible pointer-events-none"
2059+
: "conversation-tab-hidden absolute inset-0 invisible pointer-events-none"
20752060
)}
20762061
onPointerDownCapture={
20772062
canTile && !active ? () => switchTab(tab.id) : undefined

0 commit comments

Comments
 (0)