Skip to content

Commit 65b4a69

Browse files
fix(web): thread details popover closes when the card moves inline (#25731)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d145077 commit 65b4a69

9 files changed

Lines changed: 682 additions & 244 deletions

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ import { BackgroundThreadContext } from "./chat/backgroundThread";
121121
import { useLocation, useNavigate } from "@tanstack/react-router";
122122
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
123123
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
124+
import { useStore } from "zustand";
124125
import { useShallow } from "zustand/react/shallow";
125126
import {
126127
isAtomCommandInterrupted,
@@ -150,10 +151,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
150151
import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
151152
import { useMediaQuery } from "../hooks/useMediaQuery";
152153
import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider";
153-
import {
154-
RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY,
155-
type ThreadPanelPresentation,
156-
} from "../rightPanelLayout";
154+
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
157155
import { PopoverCreateHandle } from "./ui/popover";
158156
import {
159157
selectActiveRightPanel,
@@ -164,7 +162,11 @@ import {
164162
} from "../rightPanelStore";
165163
import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore";
166164
import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
167-
import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
165+
import {
166+
ThreadPreviewMiniPlayer,
167+
useThreadPreviewCanvasSource,
168+
} from "./preview/ThreadPreviewMiniPlayer";
169+
import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore";
168170
import { usePreviewSession } from "./preview/usePreviewSession";
169171
import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
170172

@@ -1004,16 +1006,8 @@ export default function ChatView(props: ChatViewProps) {
10041006
const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
10051007
const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true;
10061008
const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
1007-
const [threadPanelPresentation, setThreadPanelPresentation] =
1008-
useState<ThreadPanelPresentation>("inline");
10091009
const [threadPanelPopoverHandle] = useState(PopoverCreateHandle);
1010-
const threadPanelOpen = useRightPanelStore((state) =>
1011-
selectThreadPanelOpen(
1012-
state.threadPanelVisibilityByThreadKey,
1013-
activeThreadRef,
1014-
threadPanelPresentation,
1015-
),
1016-
);
1010+
const [chatCanvasStore] = useState(createChatCanvasStore);
10171011

10181012
const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime);
10191013
const activeThreadEnvironmentId = activeThread?.environmentId;
@@ -1856,6 +1850,39 @@ export default function ChatView(props: ChatViewProps) {
18561850
activeThreadRef?.environmentId ?? null,
18571851
AuthPreviewOperateScope,
18581852
);
1853+
const shownPreviewMiniPlayer =
1854+
activeThreadRef &&
1855+
activePreviewMiniPlayer &&
1856+
previewMiniPlayerVisible &&
1857+
(activePreviewMiniPlayer.source.kind === "device" || canOperatePreview)
1858+
? activePreviewMiniPlayer
1859+
: null;
1860+
const canvasPreview = useThreadPreviewCanvasSource(activeThreadRef, shownPreviewMiniPlayer);
1861+
const threadPanelInlineOpen = useRightPanelStore((state) =>
1862+
selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, activeThreadRef, "inline"),
1863+
);
1864+
const centerChatBesideDetails = useClientSettings((s) => s.redesignEnabled);
1865+
// Selects only the mode, so canvas resizes rerender the chat view only when the
1866+
// card switches presentation.
1867+
const threadPanelPresentation = useStore(
1868+
chatCanvasStore,
1869+
(state) =>
1870+
resolveChatCanvasModel({
1871+
measurements: state.measurements,
1872+
detailsCardMeasurement: state.detailsCardMeasurement,
1873+
threadRef: activeThreadRef,
1874+
preview: canvasPreview,
1875+
detailsCardInlineOpen: threadPanelInlineOpen,
1876+
centerBesideDetails: centerChatBesideDetails,
1877+
}).detailsCardMode,
1878+
);
1879+
const threadPanelOpen = useRightPanelStore((state) =>
1880+
selectThreadPanelOpen(
1881+
state.threadPanelVisibilityByThreadKey,
1882+
activeThreadRef,
1883+
threadPanelPresentation,
1884+
),
1885+
);
18591886
const supportsThreadPullRequests =
18601887
serverConfig?.environment.capabilities.threadPullRequests === true;
18611888
const visiblePullRequests = visibleThreadPullRequests(
@@ -3184,6 +3211,9 @@ export default function ChatView(props: ChatViewProps) {
31843211
thread: activeThreadRef,
31853212
enabled: threadFindEnabled,
31863213
}}
3214+
store={chatCanvasStore}
3215+
threadRef={activeThreadRef}
3216+
preview={canvasPreview}
31873217
detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0}
31883218
composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
31893219
data-chat-workspace-drop-target="true"
@@ -3690,14 +3720,11 @@ export default function ChatView(props: ChatViewProps) {
36903720
{canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? (
36913721
<PreviewSessionSync threadRef={activeThreadRef} />
36923722
) : null}
3693-
{activeThreadRef &&
3694-
activePreviewMiniPlayer &&
3695-
previewMiniPlayerVisible &&
3696-
(activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? (
3723+
{activeThreadRef && shownPreviewMiniPlayer ? (
36973724
<ThreadPreviewMiniPlayer
3698-
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
3725+
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(shownPreviewMiniPlayer.source)}`}
36993726
threadRef={activeThreadRef}
3700-
miniPlayer={activePreviewMiniPlayer}
3727+
miniPlayer={shownPreviewMiniPlayer}
37013728
/>
37023729
) : null}
37033730

@@ -3711,7 +3738,6 @@ export default function ChatView(props: ChatViewProps) {
37113738
<ThreadDetailsPanel
37123739
anchor={threadPanelPopoverAnchorRef}
37133740
handle={threadPanelPopoverHandle}
3714-
onPresentationChange={setThreadPanelPresentation}
37153741
forceNewWorktree={multipleModelSelections !== null}
37163742
environmentId={activeThread.environmentId}
37173743
threadId={activeThread.id}

‎apps/web/src/components/chat/ChatCanvas.tsx‎

Lines changed: 47 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,35 @@
11
import { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react";
2+
import { useStore } from "zustand";
23
import { ChatCanvasContext } from "./ChatCanvasContext";
3-
import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
4-
import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
4+
import type { ChatCanvasPreview } from "./chatCanvasLayout";
5+
import { resolveChatCanvasModel, type ChatCanvasStore } from "./canvasStore";
6+
import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore";
57
import { observeResize } from "../../lib/observeResize";
8+
import type { ScopedThreadRef } from "@t3tools/contracts";
69
import { useClientSettings } from "~/hooks/useSettings";
710

811
export function ChatCanvas({
12+
store,
13+
threadRef,
14+
preview,
915
composerOverlayElement,
1016
detailsCardTopInset = 0,
1117
children,
1218
...props
1319
}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
20+
store: ChatCanvasStore;
21+
/** The thread whose details card sits on the canvas; other threads' measurements are stale. */
22+
threadRef: ScopedThreadRef | null;
23+
/** The thread's mini player while it renders on this canvas. */
24+
preview: ChatCanvasPreview | null;
1425
composerOverlayElement: HTMLElement | null;
1526
detailsCardTopInset?: number;
1627
}) {
1728
const elementRef = useRef<HTMLDivElement | null>(null);
1829
const widthProbeRef = useRef<HTMLDivElement | null>(null);
1930
const [timelineElement, registerTimeline] = useState<HTMLElement | null>(null);
20-
const [preview, setPreview] = useState<ChatCanvasPreview | null>(null);
21-
const [detailsCard, setDetailsCard] = useState<PreviewMiniPlayerObstacles["detailsCard"]>(null);
22-
const reportDetailsCard = (next: PreviewMiniPlayerObstacles["detailsCard"]) => {
23-
setDetailsCard((current) =>
24-
current?.left === next?.left &&
25-
current?.right === next?.right &&
26-
current?.bottom === next?.bottom
27-
? current
28-
: next,
29-
);
30-
};
31-
const [measurements, setMeasurements] = useState({
32-
width: 0,
33-
height: 0,
34-
padding: 48,
35-
maxChatWidth: 768,
36-
minChatWidth: 640,
37-
composerHeight: 0,
38-
timelineGutter: 0,
39-
findBarLeft: 0,
40-
findBarRight: 0,
41-
findBarBottom: 0,
42-
});
43-
const reportPreview = (next: ChatCanvasPreview) => {
44-
setPreview((current) =>
45-
current?.key === next.key &&
46-
current.width === next.width &&
47-
current.lastInteraction === next.lastInteraction &&
48-
current.position?.x === next.position?.x &&
49-
current.position?.y === next.position?.y &&
50-
current.source.width === next.source.width &&
51-
current.source.height === next.source.height
52-
? current
53-
: next,
54-
);
55-
};
56-
const clearPreview = (key: string) =>
57-
setPreview((current) => (current?.key === key ? null : current));
31+
const measurements = useStore(store, (state) => state.measurements);
32+
const detailsCardMeasurement = useStore(store, (state) => state.detailsCardMeasurement);
5833
useLayoutEffect(() => {
5934
const element = elementRef.current;
6035
const probe = widthProbeRef.current;
@@ -65,10 +40,15 @@ export function ChatCanvas({
6540
const canvasBounds = element.getBoundingClientRect();
6641
const findBarBounds = findBar?.getBoundingClientRect();
6742
const styles = getComputedStyle(probe);
68-
const next = {
69-
findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
70-
findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
71-
findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
43+
store.getState().setMeasurements({
44+
findBar:
45+
detailsCardTopInset > 0
46+
? {
47+
left: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
48+
right: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
49+
bottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
50+
}
51+
: null,
7252
width: element.clientWidth,
7353
height: element.clientHeight,
7454
padding: Number.parseFloat(styles.paddingLeft),
@@ -78,49 +58,39 @@ export function ChatCanvas({
7858
timelineGutter: timelineElement
7959
? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2
8060
: 0,
81-
};
82-
setMeasurements((current) =>
83-
Object.keys(next).every(
84-
(key) => current[key as keyof typeof current] === next[key as keyof typeof next],
85-
)
86-
? current
87-
: next,
88-
);
61+
});
8962
};
9063
measure();
9164
const observed: Element[] = [element, probe];
9265
if (findBar) observed.push(findBar);
9366
if (composerOverlayElement) observed.push(composerOverlayElement);
9467
if (timelineElement) observed.push(timelineElement);
9568
return observeResize(observed, measure);
96-
}, [composerOverlayElement, timelineElement, detailsCardTopInset]);
97-
const redesign = useClientSettings((s) => s.redesignEnabled);
98-
const container = { width: measurements.width, height: measurements.height };
99-
const findBar =
100-
detailsCardTopInset > 0
101-
? {
102-
left: measurements.findBarLeft,
103-
right: measurements.findBarRight,
104-
bottom: measurements.findBarBottom,
105-
}
106-
: null;
69+
}, [store, composerOverlayElement, timelineElement, detailsCardTopInset]);
70+
const detailsCardInlineOpen = useRightPanelStore((state) =>
71+
threadRef === null
72+
? false
73+
: selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
74+
);
75+
const centerBesideDetails = useClientSettings((s) => s.redesignEnabled);
76+
const model = resolveChatCanvasModel({
77+
measurements,
78+
detailsCardMeasurement,
79+
threadRef,
80+
preview,
81+
detailsCardInlineOpen,
82+
centerBesideDetails,
83+
});
10784
const context = {
108-
container,
109-
lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
110-
layout: resolveChatCanvasLayout({
111-
...measurements,
112-
container,
113-
preview,
114-
detailsCard,
115-
findBar,
116-
centerBesideDetails: redesign,
117-
}),
85+
container: model.container,
86+
lane: model.lane,
87+
layout: model.layout,
11888
previewKey: preview?.key ?? null,
119-
reportPreview,
120-
clearPreview,
12189
registerTimeline,
122-
reportDetailsCard,
123-
detailsCardTopInset: findBar?.bottom ?? 0,
90+
measureDetailsCard: store.getState().measureDetailsCard,
91+
detailsCardTopInset: model.detailsCardTopInset,
92+
preferredDetailsCard: model.preferredDetailsCard,
93+
detailsCardPlacement: model.detailsCardPlacement,
12494
};
12595
const { layout } = context;
12696
return (
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import { createContext, useContext } from "react";
2-
import type { ChatCanvasPreview, resolveChatCanvasLayout } from "./chatCanvasLayout";
3-
import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
2+
import type { resolveChatCanvasLayout } from "./chatCanvasLayout";
3+
import type { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout";
4+
import type { ScopedThreadRef } from "@t3tools/contracts";
5+
6+
/** The inline details card's measured full-density height at a card width. */
7+
export interface DetailsCardMeasurement {
8+
readonly threadRef: ScopedThreadRef;
9+
readonly width: number;
10+
readonly fullContentHeight: number;
11+
}
412

513
export const ChatCanvasContext = createContext<{
614
container: { width: number; height: number };
715
lane: { padding: number; minChatWidth: number };
816
layout: ReturnType<typeof resolveChatCanvasLayout>;
917
previewKey: string | null;
10-
reportPreview: (preview: ChatCanvasPreview) => void;
11-
clearPreview: (key: string) => void;
1218
registerTimeline: (element: HTMLElement | null) => void;
13-
reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void;
19+
measureDetailsCard: (measurement: DetailsCardMeasurement) => void;
1420
detailsCardTopInset: number;
21+
/** Where an inline card sits when nothing overlaps it; null when the canvas is too narrow. */
22+
preferredDetailsCard: ReturnType<typeof resolveThreadDetailsCardLayout>;
23+
/** Where the inline card sits now; null when it shows as a popover. */
24+
detailsCardPlacement: ReturnType<typeof resolveThreadDetailsCardLayout>;
1525
} | null>(null);
1626

1727
export const useChatCanvas = () => useContext(ChatCanvasContext);

0 commit comments

Comments
 (0)