diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index aefa9ab9f41..07ec0dc9077 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -56,8 +56,6 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const pathnameRef = useRef(pathname); - pathnameRef.current = pathname; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; @@ -95,7 +93,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); if (!isSettingsRoute) { void navigate({ to: "/settings" }); } @@ -105,7 +103,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return () => { unsubscribe?.(); }; - }, [navigate]); + }, [navigate, pathname]); return ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..77951e7079a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,6 +53,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -201,6 +202,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + DRAFT_HERO_TRANSITION_ANIMATION_ID, + DRAFT_HERO_TRANSITION_DURATION_MS, + DRAFT_HERO_TRANSITION_EASING, + MOBILE_COMPOSER_VIEW_TRANSITION_NAME, + MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, + runMobileComposerTransition, +} from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, @@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { + const transitionGroupRef = useRef(null); + const composerAnchorRef = useRef(null); + const previousStateRef = useRef(isDraftHeroState); + const previousComposerRectRef = useRef(null); + const animationRef = useRef(null); + const attachTransitionGroupRef = (element: HTMLDivElement | null) => { + transitionGroupRef.current = element; + }; + const attachComposerAnchorRef = (element: HTMLDivElement | null) => { + composerAnchorRef.current = element; + }; + const captureComposerRect = () => { + previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; + }; + + useLayoutEffect(() => { + const transitionGroup = transitionGroupRef.current; + const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; + const stateChanged = previousStateRef.current !== isDraftHeroState; + const prefersReducedMotion = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const mobileComposerTransitionActive = + typeof document !== "undefined" && + document.documentElement.dataset.mobileComposerRouteTransition === "true"; + + animationRef.current?.cancel(); + animationRef.current = null; + + const previousComposerRect = previousComposerRectRef.current; + if ( + stateChanged && + !prefersReducedMotion && + !mobileComposerTransitionActive && + transitionGroup && + previousComposerRect && + nextComposerRect && + typeof transitionGroup.animate === "function" + ) { + const translateX = previousComposerRect.left - nextComposerRect.left; + const translateY = previousComposerRect.top - nextComposerRect.top; + if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { + const animation = transitionGroup.animate( + [ + { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: DRAFT_HERO_TRANSITION_DURATION_MS, + easing: DRAFT_HERO_TRANSITION_EASING, + }, + ); + animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; + animationRef.current = animation; + void animation.finished + .catch(() => undefined) + .then(() => { + if (animationRef.current !== animation) { + return; + } + animationRef.current = null; + }); + } + } + + previousStateRef.current = isDraftHeroState; + previousComposerRectRef.current = nextComposerRect; + }, [isDraftHeroState]); + + return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; +} const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); @@ -339,6 +421,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "server"; draftId?: never; } @@ -347,6 +430,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -979,6 +1063,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane ); }); +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, + forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( @@ -1035,10 +1128,10 @@ function ChatViewContent(props: ChatViewProps) { ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); + const serverThread = useThread(routeThreadRef); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore((store) => - routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + const activeThreadLastVisitedAt = useUiStateStore( + (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); const setStickyComposerModelSelection = useComposerDraftStore( @@ -1103,10 +1196,10 @@ function ChatViewContent(props: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< - Record + Record >({}); const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< - Record + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -1218,11 +1311,45 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); - const localDraftError = - routeKind === "server" && serverThread - ? null - : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); - const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; + const localDraftError = serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; + // Draft errors are keyed by draftId while server errors are keyed by thread + // key, so a pending draft entry must migrate when the server thread loads or + // a failed send would silently disappear on promotion. When both keys hold + // an entry, the most recent write wins. + useEffect(() => { + if (!serverThread || !draftId) { + return; + } + const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; + if (pendingDraftEntry === undefined) { + return; + } + setLocalDraftErrorsByDraftId((existing) => { + if (existing[draftId] === undefined) { + return existing; + } + const next = { ...existing }; + delete next[draftId]; + return next; + }); + setLocalServerErrorsByThreadKey((existing) => { + const currentEntry = existing[routeThreadKey]; + if ( + currentEntry !== undefined && + (currentEntry.at > pendingDraftEntry.at || + currentEntry.message === pendingDraftEntry.message) + ) { + return existing; + } + return { + ...existing, + [routeThreadKey]: pendingDraftEntry, + }; + }); + }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); const localDraftThread = useMemo( () => draftThread @@ -1237,7 +1364,10 @@ function ChatViewContent(props: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== null; + // Promotion is data-driven: the draft route keeps rendering while the + // server thread (same pre-allocated ref) starts, so live state must not + // depend on which route is mounted. + const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) @@ -2063,6 +2193,16 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); + const draftHeroDockRequested = + activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; + const isDraftHeroState = + isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const [ + attachDraftHeroTransitionGroupRef, + attachDraftHeroComposerAnchorRef, + captureDraftHeroComposerRect, + ] = useDraftHeroLayoutTransition(isDraftHeroState); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { @@ -2226,6 +2366,7 @@ function ChatViewContent(props: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( serverThread && targetThreadId === routeThreadRef.threadId && @@ -2233,24 +2374,24 @@ function ChatViewContent(props: ChatViewProps) { serverThread.id === targetThreadId ) { setLocalServerErrorsByThreadKey((existing) => { - if ((existing[routeThreadKey] ?? null) === nextError) { + if ((existing[routeThreadKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [routeThreadKey]: nextError, + [routeThreadKey]: nextEntry, }; }); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey] ?? null) === nextError) { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [localDraftErrorKey]: nextError, + [localDraftErrorKey]: nextEntry, }; }); }, @@ -3963,7 +4104,16 @@ function ChatViewContent(props: ChatViewProps) { } return; } - if (!activeProject) return; + if (!activeProject) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Choose a project first", + description: "This draft no longer points to an available project.", + }), + ); + return; + } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = @@ -3981,6 +4131,21 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; @@ -4234,6 +4399,9 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = false; if (!turnStartSucceeded) { + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); resetLocalDispatch(); } }; @@ -5101,6 +5269,7 @@ function ChatViewContent(props: ChatViewProps) { contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + hideEmptyPlaceholder={isDraftHeroState} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5123,134 +5292,187 @@ function ChatViewContent(props: ChatViewProps) { )} - {/* Input bar */} + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
-