Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 47 additions & 21 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import { BackgroundThreadContext } from "./chat/backgroundThread";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -150,10 +151,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider";
import {
RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY,
type ThreadPanelPresentation,
} from "../rightPanelLayout";
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
import { PopoverCreateHandle } from "./ui/popover";
import {
selectActiveRightPanel,
Expand All @@ -164,7 +162,11 @@ import {
} from "../rightPanelStore";
import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore";
import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
import {
ThreadPreviewMiniPlayer,
useThreadPreviewCanvasSource,
} from "./preview/ThreadPreviewMiniPlayer";
import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore";
import { usePreviewSession } from "./preview/usePreviewSession";
import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";

Expand Down Expand Up @@ -1004,16 +1006,8 @@ export default function ChatView(props: ChatViewProps) {
const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true;
const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
const [threadPanelPresentation, setThreadPanelPresentation] =
useState<ThreadPanelPresentation>("inline");
const [threadPanelPopoverHandle] = useState(PopoverCreateHandle);
const threadPanelOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(
state.threadPanelVisibilityByThreadKey,
activeThreadRef,
threadPanelPresentation,
),
);
const [chatCanvasStore] = useState(createChatCanvasStore);

const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime);
const activeThreadEnvironmentId = activeThread?.environmentId;
Expand Down Expand Up @@ -1856,6 +1850,39 @@ export default function ChatView(props: ChatViewProps) {
activeThreadRef?.environmentId ?? null,
AuthPreviewOperateScope,
);
const shownPreviewMiniPlayer =
activeThreadRef &&
activePreviewMiniPlayer &&
previewMiniPlayerVisible &&
(activePreviewMiniPlayer.source.kind === "device" || canOperatePreview)
? activePreviewMiniPlayer
: null;
const canvasPreview = useThreadPreviewCanvasSource(activeThreadRef, shownPreviewMiniPlayer);
const threadPanelInlineOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, activeThreadRef, "inline"),
);
const centerChatBesideDetails = useClientSettings((s) => s.redesignEnabled);
// Selects only the mode, so canvas resizes rerender the chat view only when the
// card switches presentation.
const threadPanelPresentation = useStore(
chatCanvasStore,
(state) =>
resolveChatCanvasModel({
measurements: state.measurements,
detailsCardMeasurement: state.detailsCardMeasurement,
threadRef: activeThreadRef,
preview: canvasPreview,
detailsCardInlineOpen: threadPanelInlineOpen,
centerBesideDetails: centerChatBesideDetails,
}).detailsCardMode,
);
const threadPanelOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(
state.threadPanelVisibilityByThreadKey,
activeThreadRef,
threadPanelPresentation,
),
);
const supportsThreadPullRequests =
serverConfig?.environment.capabilities.threadPullRequests === true;
const visiblePullRequests = visibleThreadPullRequests(
Expand Down Expand Up @@ -3184,6 +3211,9 @@ export default function ChatView(props: ChatViewProps) {
thread: activeThreadRef,
enabled: threadFindEnabled,
}}
store={chatCanvasStore}
threadRef={activeThreadRef}
preview={canvasPreview}
detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0}
composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
data-chat-workspace-drop-target="true"
Expand Down Expand Up @@ -3690,14 +3720,11 @@ export default function ChatView(props: ChatViewProps) {
{canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? (
<PreviewSessionSync threadRef={activeThreadRef} />
) : null}
{activeThreadRef &&
activePreviewMiniPlayer &&
previewMiniPlayerVisible &&
(activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? (
{activeThreadRef && shownPreviewMiniPlayer ? (
<ThreadPreviewMiniPlayer
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(shownPreviewMiniPlayer.source)}`}
threadRef={activeThreadRef}
miniPlayer={activePreviewMiniPlayer}
miniPlayer={shownPreviewMiniPlayer}
/>
) : null}

Expand All @@ -3711,7 +3738,6 @@ export default function ChatView(props: ChatViewProps) {
<ThreadDetailsPanel
anchor={threadPanelPopoverAnchorRef}
handle={threadPanelPopoverHandle}
onPresentationChange={setThreadPanelPresentation}
forceNewWorktree={multipleModelSelections !== null}
environmentId={activeThread.environmentId}
threadId={activeThread.id}
Expand Down
124 changes: 47 additions & 77 deletions apps/web/src/components/chat/ChatCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,35 @@
import { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react";
import { useStore } from "zustand";
import { ChatCanvasContext } from "./ChatCanvasContext";
import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
import type { ChatCanvasPreview } from "./chatCanvasLayout";
import { resolveChatCanvasModel, type ChatCanvasStore } from "./canvasStore";
import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore";
import { observeResize } from "../../lib/observeResize";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { useClientSettings } from "~/hooks/useSettings";

export function ChatCanvas({
store,
threadRef,
preview,
composerOverlayElement,
detailsCardTopInset = 0,
children,
...props
}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
store: ChatCanvasStore;
/** The thread whose details card sits on the canvas; other threads' measurements are stale. */
threadRef: ScopedThreadRef | null;
/** The thread's mini player while it renders on this canvas. */
preview: ChatCanvasPreview | null;
composerOverlayElement: HTMLElement | null;
detailsCardTopInset?: number;
}) {
const elementRef = useRef<HTMLDivElement | null>(null);
const widthProbeRef = useRef<HTMLDivElement | null>(null);
const [timelineElement, registerTimeline] = useState<HTMLElement | null>(null);
const [preview, setPreview] = useState<ChatCanvasPreview | null>(null);
const [detailsCard, setDetailsCard] = useState<PreviewMiniPlayerObstacles["detailsCard"]>(null);
const reportDetailsCard = (next: PreviewMiniPlayerObstacles["detailsCard"]) => {
setDetailsCard((current) =>
current?.left === next?.left &&
current?.right === next?.right &&
current?.bottom === next?.bottom
? current
: next,
);
};
const [measurements, setMeasurements] = useState({
width: 0,
height: 0,
padding: 48,
maxChatWidth: 768,
minChatWidth: 640,
composerHeight: 0,
timelineGutter: 0,
findBarLeft: 0,
findBarRight: 0,
findBarBottom: 0,
});
const reportPreview = (next: ChatCanvasPreview) => {
setPreview((current) =>
current?.key === next.key &&
current.width === next.width &&
current.lastInteraction === next.lastInteraction &&
current.position?.x === next.position?.x &&
current.position?.y === next.position?.y &&
current.source.width === next.source.width &&
current.source.height === next.source.height
? current
: next,
);
};
const clearPreview = (key: string) =>
setPreview((current) => (current?.key === key ? null : current));
const measurements = useStore(store, (state) => state.measurements);
const detailsCardMeasurement = useStore(store, (state) => state.detailsCardMeasurement);
useLayoutEffect(() => {
const element = elementRef.current;
const probe = widthProbeRef.current;
Expand All @@ -65,10 +40,15 @@ export function ChatCanvas({
const canvasBounds = element.getBoundingClientRect();
const findBarBounds = findBar?.getBoundingClientRect();
const styles = getComputedStyle(probe);
const next = {
findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
store.getState().setMeasurements({
findBar:
detailsCardTopInset > 0
? {
left: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
right: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
bottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
}
: null,
width: element.clientWidth,
height: element.clientHeight,
padding: Number.parseFloat(styles.paddingLeft),
Expand All @@ -78,49 +58,39 @@ export function ChatCanvas({
timelineGutter: timelineElement
? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2
: 0,
};
setMeasurements((current) =>
Object.keys(next).every(
(key) => current[key as keyof typeof current] === next[key as keyof typeof next],
)
? current
: next,
);
});
};
measure();
const observed: Element[] = [element, probe];
if (findBar) observed.push(findBar);
if (composerOverlayElement) observed.push(composerOverlayElement);
if (timelineElement) observed.push(timelineElement);
return observeResize(observed, measure);
}, [composerOverlayElement, timelineElement, detailsCardTopInset]);
const redesign = useClientSettings((s) => s.redesignEnabled);
const container = { width: measurements.width, height: measurements.height };
const findBar =
detailsCardTopInset > 0
? {
left: measurements.findBarLeft,
right: measurements.findBarRight,
bottom: measurements.findBarBottom,
}
: null;
}, [store, composerOverlayElement, timelineElement, detailsCardTopInset]);
const detailsCardInlineOpen = useRightPanelStore((state) =>
threadRef === null
? false
: selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
);
const centerBesideDetails = useClientSettings((s) => s.redesignEnabled);
const model = resolveChatCanvasModel({
measurements,
detailsCardMeasurement,
threadRef,
preview,
detailsCardInlineOpen,
centerBesideDetails,
});
const context = {
container,
lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
layout: resolveChatCanvasLayout({
...measurements,
container,
preview,
detailsCard,
findBar,
centerBesideDetails: redesign,
}),
container: model.container,
lane: model.lane,
layout: model.layout,
previewKey: preview?.key ?? null,
reportPreview,
clearPreview,
registerTimeline,
reportDetailsCard,
detailsCardTopInset: findBar?.bottom ?? 0,
measureDetailsCard: store.getState().measureDetailsCard,
detailsCardTopInset: model.detailsCardTopInset,
preferredDetailsCard: model.preferredDetailsCard,
detailsCardPlacement: model.detailsCardPlacement,
};
const { layout } = context;
return (
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/components/chat/ChatCanvasContext.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { createContext, useContext } from "react";
import type { ChatCanvasPreview, resolveChatCanvasLayout } from "./chatCanvasLayout";
import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
import type { resolveChatCanvasLayout } from "./chatCanvasLayout";
import type { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout";
import type { ScopedThreadRef } from "@t3tools/contracts";

/** The inline details card's measured full-density height at a card width. */
export interface DetailsCardMeasurement {
readonly threadRef: ScopedThreadRef;
readonly width: number;
readonly fullContentHeight: number;
}

export const ChatCanvasContext = createContext<{
container: { width: number; height: number };
lane: { padding: number; minChatWidth: number };
layout: ReturnType<typeof resolveChatCanvasLayout>;
previewKey: string | null;
reportPreview: (preview: ChatCanvasPreview) => void;
clearPreview: (key: string) => void;
registerTimeline: (element: HTMLElement | null) => void;
reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void;
measureDetailsCard: (measurement: DetailsCardMeasurement) => void;
detailsCardTopInset: number;
/** Where an inline card sits when nothing overlaps it; null when the canvas is too narrow. */
preferredDetailsCard: ReturnType<typeof resolveThreadDetailsCardLayout>;
/** Where the inline card sits now; null when it shows as a popover. */
detailsCardPlacement: ReturnType<typeof resolveThreadDetailsCardLayout>;
} | null>(null);

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