authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-07-18 06:45:45-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-18 12:45:45+02:00
log1bd2fa25e2214e0318a8a0d4cd4c4c513ddd800e
tree320301669794c348a9e77d6fe4ddec67874067c0
parent5ca32661b7dc8d512305c3bb9237d994a41a1af5
signature Signed by PGP key B5690EEEBB952194

feat: draft hero landing on the index route (#4055)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

15 files changed, 1102 insertions(+), 193 deletions(-)

apps/web/src/components/AppSidebarLayout.tsx+3-5
......@@ -1,5 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
33import { useLocation, useNavigate } from "@tanstack/react-router";
44
55import { isElectron } from "../env";
......@@ -56,8 +56,6 @@ function SidebarControl() {
5656export function AppSidebarLayout({ children }: { children: ReactNode }) {
5757 const navigate = useNavigate();
5858 const pathname = useLocation({ select: (location) => location.pathname });
59 const pathnameRef = useRef(pathname);
60 pathnameRef.current = pathname;
6159 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
6260 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {
6361 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;
......@@ -95,7 +93,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
9593
9694 const unsubscribe = onMenuAction((action) => {
9795 if (action === "open-settings") {
98 const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current);
96 const isSettingsRoute = /^\/settings(\/|$)/.test(pathname);
9997 if (!isSettingsRoute) {
10098 void navigate({ to: "/settings" });
10199 }
......@@ -105,7 +103,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
105103 return () => {
106104 unsubscribe?.();
107105 };
108 }, [navigate]);
106 }, [navigate, pathname]);
109107
110108 return (
111109 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
apps/web/src/components/ChatView.tsx+357-135
......@@ -53,6 +53,7 @@ import {
5353 useRef,
5454 useState,
5555} from "react";
56import { flushSync } from "react-dom";
5657import { useNavigate } from "@tanstack/react-router";
5758import { useShallow } from "zustand/react/shallow";
5859import {
......@@ -201,6 +202,7 @@ import {
201202} from "../state/entities";
202203import { environmentShell } from "../state/shell";
203204import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
205import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
204206import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
205207import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
206208import { MessagesTimeline } from "./chat/MessagesTimeline";
......@@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";
212214import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
213215import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
214216import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
217import {
218 DRAFT_HERO_TRANSITION_ANIMATION_ID,
219 DRAFT_HERO_TRANSITION_DURATION_MS,
220 DRAFT_HERO_TRANSITION_EASING,
221 MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
222 MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
223 runMobileComposerTransition,
224} from "./chat/draftHeroTransition";
215225import {
216226 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
217227 buildExpiredTerminalContextToastCopy,
......@@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
256266const EMPTY_PROVIDERS: ServerProvider[] = [];
257267const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
258268const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
269function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
270 const transitionGroupRef = useRef<HTMLDivElement | null>(null);
271 const composerAnchorRef = useRef<HTMLDivElement | null>(null);
272 const previousStateRef = useRef(isDraftHeroState);
273 const previousComposerRectRef = useRef<DOMRect | null>(null);
274 const animationRef = useRef<Animation | null>(null);
275 const attachTransitionGroupRef = (element: HTMLDivElement | null) => {
276 transitionGroupRef.current = element;
277 };
278 const attachComposerAnchorRef = (element: HTMLDivElement | null) => {
279 composerAnchorRef.current = element;
280 };
281 const captureComposerRect = () => {
282 previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null;
283 };
284
285 useLayoutEffect(() => {
286 const transitionGroup = transitionGroupRef.current;
287 const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null;
288 const stateChanged = previousStateRef.current !== isDraftHeroState;
289 const prefersReducedMotion =
290 typeof window !== "undefined" &&
291 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
292 const mobileComposerTransitionActive =
293 typeof document !== "undefined" &&
294 document.documentElement.dataset.mobileComposerRouteTransition === "true";
295
296 animationRef.current?.cancel();
297 animationRef.current = null;
298
299 const previousComposerRect = previousComposerRectRef.current;
300 if (
301 stateChanged &&
302 !prefersReducedMotion &&
303 !mobileComposerTransitionActive &&
304 transitionGroup &&
305 previousComposerRect &&
306 nextComposerRect &&
307 typeof transitionGroup.animate === "function"
308 ) {
309 const translateX = previousComposerRect.left - nextComposerRect.left;
310 const translateY = previousComposerRect.top - nextComposerRect.top;
311 if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) {
312 const animation = transitionGroup.animate(
313 [
314 { transform: `translate3d(${translateX}px, ${translateY}px, 0)` },
315 { transform: "translate3d(0, 0, 0)" },
316 ],
317 {
318 duration: DRAFT_HERO_TRANSITION_DURATION_MS,
319 easing: DRAFT_HERO_TRANSITION_EASING,
320 },
321 );
322 animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID;
323 animationRef.current = animation;
324 void animation.finished
325 .catch(() => undefined)
326 .then(() => {
327 if (animationRef.current !== animation) {
328 return;
329 }
330 animationRef.current = null;
331 });
332 }
333 }
334
335 previousStateRef.current = isDraftHeroState;
336 previousComposerRectRef.current = nextComposerRect;
337 }, [isDraftHeroState]);
338
339 return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const;
340}
259341const PreviewPanel = lazy(() =>
260342 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
261343);
......@@ -339,6 +421,7 @@ type ChatViewProps =
339421 threadId: ThreadId;
340422 onDiffPanelOpen?: () => void;
341423 reserveTitleBarControlInset?: boolean;
424 forceExpandedMobileComposer?: boolean;
342425 routeKind: "server";
343426 draftId?: never;
344427 }
......@@ -347,6 +430,7 @@ type ChatViewProps =
347430 threadId: ThreadId;
348431 onDiffPanelOpen?: () => void;
349432 reserveTitleBarControlInset?: boolean;
433 forceExpandedMobileComposer?: boolean;
350434 routeKind: "draft";
351435 draftId: DraftId;
352436 };
......@@ -979,6 +1063,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
9791063 );
9801064});
9811065
1066// Errors surface through two maps (draft-keyed and thread-keyed) whose entries
1067// can race around promotion, so each write carries its time to let the latest
1068// one win when they collide.
1069type LocalThreadErrorEntry = {
1070 readonly message: string | null;
1071 readonly at: number;
1072};
1073
9821074function ChatViewContent(props: ChatViewProps) {
9831075 const {
9841076 environmentId,
......@@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) {
9861078 routeKind,
9871079 onDiffPanelOpen,
9881080 reserveTitleBarControlInset = true,
1081 forceExpandedMobileComposer = false,
9891082 } = props;
9901083 const draftId = routeKind === "draft" ? props.draftId : null;
9911084 const routeThreadRef = useMemo(
......@@ -1035,10 +1128,10 @@ function ChatViewContent(props: ChatViewProps) {
10351128 );
10361129 const composerDraftTarget: ScopedThreadRef | DraftId =
10371130 routeKind === "server" ? routeThreadRef : props.draftId;
1038 const serverThread = useThread(routeKind === "server" ? routeThreadRef : null);
1131 const serverThread = useThread(routeThreadRef);
10391132 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1040 const activeThreadLastVisitedAt = useUiStateStore((store) =>
1041 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
1133 const activeThreadLastVisitedAt = useUiStateStore(
1134 (store) => store.threadLastVisitedAtById[routeThreadKey],
10421135 );
10431136 const settings = useEnvironmentSettings(environmentId);
10441137 const setStickyComposerModelSelection = useComposerDraftStore(
......@@ -1103,10 +1196,10 @@ function ChatViewContent(props: ChatViewProps) {
11031196 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
11041197 optimisticUserMessagesRef.current = optimisticUserMessages;
11051198 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
1106 Record<string, string | null>
1199 Record<string, LocalThreadErrorEntry>
11071200 >({});
11081201 const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState<
1109 Record<string, string | null>
1202 Record<string, LocalThreadErrorEntry>
11101203 >({});
11111204 const [isConnecting, _setIsConnecting] = useState(false);
11121205 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
......@@ -1218,11 +1311,45 @@ function ChatViewContent(props: ChatViewProps) {
12181311 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
12191312 : null;
12201313 const fallbackDraftProject = useProject(fallbackDraftProjectRef);
1221 const localDraftError =
1222 routeKind === "server" && serverThread
1223 ? null
1224 : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);
1225 const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null;
1314 const localDraftError = serverThread
1315 ? null
1316 : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
1317 const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
1318 // Draft errors are keyed by draftId while server errors are keyed by thread
1319 // key, so a pending draft entry must migrate when the server thread loads or
1320 // a failed send would silently disappear on promotion. When both keys hold
1321 // an entry, the most recent write wins.
1322 useEffect(() => {
1323 if (!serverThread || !draftId) {
1324 return;
1325 }
1326 const pendingDraftEntry = localDraftErrorsByDraftId[draftId];
1327 if (pendingDraftEntry === undefined) {
1328 return;
1329 }
1330 setLocalDraftErrorsByDraftId((existing) => {
1331 if (existing[draftId] === undefined) {
1332 return existing;
1333 }
1334 const next = { ...existing };
1335 delete next[draftId];
1336 return next;
1337 });
1338 setLocalServerErrorsByThreadKey((existing) => {
1339 const currentEntry = existing[routeThreadKey];
1340 if (
1341 currentEntry !== undefined &&
1342 (currentEntry.at > pendingDraftEntry.at ||
1343 currentEntry.message === pendingDraftEntry.message)
1344 ) {
1345 return existing;
1346 }
1347 return {
1348 ...existing,
1349 [routeThreadKey]: pendingDraftEntry,
1350 };
1351 });
1352 }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]);
12261353 const localDraftThread = useMemo(
12271354 () =>
12281355 draftThread
......@@ -1237,7 +1364,10 @@ function ChatViewContent(props: ChatViewProps) {
12371364 : undefined,
12381365 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
12391366 );
1240 const isServerThread = routeKind === "server" && serverThread !== null;
1367 // Promotion is data-driven: the draft route keeps rendering while the
1368 // server thread (same pre-allocated ref) starts, so live state must not
1369 // depend on which route is mounted.
1370 const isServerThread = serverThread !== null;
12411371 const activeThread = isServerThread ? serverThread : localDraftThread;
12421372 const threadError = isServerThread
12431373 ? (localServerError ?? serverThread?.session?.lastError ?? null)
......@@ -2063,6 +2193,16 @@ function ChatViewContent(props: ChatViewProps) {
20632193 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
20642194 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
20652195 );
2196 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
2197 const draftHeroDockRequested =
2198 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
2199 const isDraftHeroState =
2200 isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested;
2201 const [
2202 attachDraftHeroTransitionGroupRef,
2203 attachDraftHeroComposerAnchorRef,
2204 captureDraftHeroComposerRect,
2205 ] = useDraftHeroLayoutTransition(isDraftHeroState);
20662206 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
20672207 useTurnDiffSummaries(activeThread);
20682208 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
......@@ -2226,6 +2366,7 @@ function ChatViewContent(props: ChatViewProps) {
22262366 (targetThreadId: ThreadId | null, error: string | null) => {
22272367 if (!targetThreadId) return;
22282368 const nextError = sanitizeThreadErrorMessage(error);
2369 const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
22292370 if (
22302371 serverThread &&
22312372 targetThreadId === routeThreadRef.threadId &&
......@@ -2233,24 +2374,24 @@ function ChatViewContent(props: ChatViewProps) {
22332374 serverThread.id === targetThreadId
22342375 ) {
22352376 setLocalServerErrorsByThreadKey((existing) => {
2236 if ((existing[routeThreadKey] ?? null) === nextError) {
2377 if ((existing[routeThreadKey]?.message ?? null) === nextError) {
22372378 return existing;
22382379 }
22392380 return {
22402381 ...existing,
2241 [routeThreadKey]: nextError,
2382 [routeThreadKey]: nextEntry,
22422383 };
22432384 });
22442385 return;
22452386 }
22462387 const localDraftErrorKey = draftId ?? targetThreadId;
22472388 setLocalDraftErrorsByDraftId((existing) => {
2248 if ((existing[localDraftErrorKey] ?? null) === nextError) {
2389 if ((existing[localDraftErrorKey]?.message ?? null) === nextError) {
22492390 return existing;
22502391 }
22512392 return {
22522393 ...existing,
2253 [localDraftErrorKey]: nextError,
2394 [localDraftErrorKey]: nextEntry,
22542395 };
22552396 });
22562397 },
......@@ -3963,7 +4104,16 @@ function ChatViewContent(props: ChatViewProps) {
39634104 }
39644105 return;
39654106 }
3966 if (!activeProject) return;
4107 if (!activeProject) {
4108 toastManager.add(
4109 stackedThreadToast({
4110 type: "warning",
4111 title: "Choose a project first",
4112 description: "This draft no longer points to an available project.",
4113 }),
4114 );
4115 return;
4116 }
39674117 const threadIdForSend = activeThread.id;
39684118 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
39694119 const baseBranchForWorktree =
......@@ -3981,6 +4131,21 @@ function ChatViewContent(props: ChatViewProps) {
39814131 }
39824132
39834133 sendInFlightRef.current = true;
4134 if (isDraftHeroState && activeThreadKey) {
4135 let resolveDockStarted: (() => void) | undefined;
4136 const dockStarted = new Promise<void>((resolve) => {
4137 resolveDockStarted = resolve;
4138 });
4139 const dockTransition = runMobileComposerTransition(() => {
4140 flushSync(() => {
4141 captureDraftHeroComposerRect();
4142 setDockedDraftHeroThreadKey(activeThreadKey);
4143 });
4144 resolveDockStarted?.();
4145 });
4146 void dockTransition.catch(() => resolveDockStarted?.());
4147 await dockStarted;
4148 }
39844149 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
39854150
39864151 const composerImagesSnapshot = [...composerImages];
......@@ -4234,6 +4399,9 @@ function ChatViewContent(props: ChatViewProps) {
42344399 }
42354400 sendInFlightRef.current = false;
42364401 if (!turnStartSucceeded) {
4402 setDockedDraftHeroThreadKey((currentThreadKey) =>
4403 currentThreadKey === activeThreadKey ? null : currentThreadKey,
4404 );
42374405 resetLocalDispatch();
42384406 }
42394407 };
......@@ -5101,6 +5269,7 @@ function ChatViewContent(props: ChatViewProps) {
51015269 contentInsetEndAdjustment={composerOverlayHeight}
51025270 onIsAtEndChange={onIsAtEndChange}
51035271 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
5272 hideEmptyPlaceholder={isDraftHeroState}
51045273 />
51055274
51065275 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
......@@ -5123,134 +5292,187 @@ function ChatViewContent(props: ChatViewProps) {
51235292 )}
51245293 </div>
51255294
5126 {/* Input bar */}
5295 {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
51275296 <div
51285297 ref={setComposerOverlayElement}
51295298 data-chat-composer-overlay="true"
5130 className="pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"
5299 className={
5300 isDraftHeroState
5301 ? "pointer-events-none absolute inset-0 z-20 flex items-center"
5302 : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"
5303 }
51315304 >
5132 <div
5133 aria-hidden="true"
5134 className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2"
5135 >
5136 <div className="relative mx-auto h-full w-full max-w-3xl overflow-clip rounded-t-[20px]">
5137 <div className="chat-composer-shared-blur absolute -inset-8" />
5138 </div>
5139 </div>
5140 <div className="chat-composer-horizontal-inset">
5141 <div className="pointer-events-auto relative z-10 isolate">
5142 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5143 <div className="relative z-10">
5144 <ChatComposer
5145 composerRef={composerRef}
5146 composerDraftTarget={composerDraftTarget}
5147 environmentId={environmentId}
5148 routeKind={routeKind}
5149 routeThreadRef={routeThreadRef}
5150 draftId={draftId}
5151 activeThreadId={activeThreadId}
5152 activeThreadEnvironmentId={activeThread?.environmentId}
5153 activeThread={activeThread}
5154 isServerThread={isServerThread}
5155 isLocalDraftThread={isLocalDraftThread}
5156 phase={phase}
5157 isConnecting={isConnecting}
5158 isSendBusy={isSendBusy}
5159 isPreparingWorktree={isPreparingWorktree}
5160 environmentUnavailable={activeEnvironmentUnavailableState}
5161 activePendingApproval={activePendingApproval}
5162 pendingApprovals={pendingApprovals}
5163 pendingUserInputs={pendingUserInputs}
5164 activePendingProgress={activePendingProgress}
5165 activePendingResolvedAnswers={activePendingResolvedAnswers}
5166 activePendingIsResponding={activePendingIsResponding}
5167 activePendingDraftAnswers={activePendingDraftAnswers}
5168 activePendingQuestionIndex={activePendingQuestionIndex}
5169 respondingRequestIds={respondingRequestIds}
5170 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
5171 activeProposedPlan={activeProposedPlan}
5172 activePlan={activePlan as { turnId?: TurnId } | null}
5173 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
5174 planSidebarLabel={planSidebarLabel}
5175 planSidebarOpen={planSidebarOpen}
5176 runtimeMode={runtimeMode}
5177 interactionMode={interactionMode}
5178 lockedProvider={lockedProvider}
5179 providerStatuses={providerStatuses as ServerProvider[]}
5180 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
5181 activeThreadModelSelection={activeThread?.modelSelection}
5182 activeThreadActivities={activeThread?.activities}
5183 resolvedTheme={resolvedTheme}
5184 settings={settings}
5185 keybindings={keybindings}
5186 terminalOpen={Boolean(terminalUiState.terminalOpen)}
5187 gitCwd={gitCwd}
5188 promptRef={promptRef}
5189 composerImagesRef={composerImagesRef}
5190 composerTerminalContextsRef={composerTerminalContextsRef}
5191 composerElementContextsRef={composerElementContextsRef}
5192 onSend={onSend}
5193 onInterrupt={onInterrupt}
5194 onImplementPlanInNewThread={onImplementPlanInNewThread}
5195 onRespondToApproval={onRespondToApproval}
5196 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
5197 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
5198 onPreviousActivePendingUserInputQuestion={
5199 onPreviousActivePendingUserInputQuestion
5200 }
5201 onChangeActivePendingUserInputCustomAnswer={
5202 onChangeActivePendingUserInputCustomAnswer
5203 }
5204 onProviderModelSelect={onProviderModelSelect}
5205 getModelDisabledReason={getModelDisabledReason}
5206 toggleInteractionMode={toggleInteractionMode}
5207 handleRuntimeModeChange={handleRuntimeModeChange}
5208 handleInteractionModeChange={handleInteractionModeChange}
5209 togglePlanSidebar={togglePlanSidebar}
5210 focusComposer={focusComposer}
5211 scheduleComposerFocus={scheduleComposerFocus}
5212 setThreadError={setThreadError}
5213 onExpandImage={onExpandTimelineImage}
5214 />
5305 {!isDraftHeroState ? (
5306 <div
5307 key="docked-composer-blur"
5308 aria-hidden="true"
5309 className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2"
5310 >
5311 <div className="relative mx-auto h-full w-full max-w-3xl overflow-clip rounded-t-[20px]">
5312 <div className="chat-composer-shared-blur absolute -inset-8" />
52155313 </div>
52165314 </div>
5217 </div>
5315 ) : null}
52185316 <div
5219 className={cn(
5220 "chat-composer-horizontal-inset chat-composer-lower-chrome relative z-10",
5221 isGitRepo
5222 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
5223 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
5224 )}
5317 ref={attachDraftHeroTransitionGroupRef}
5318 className="chat-composer-horizontal-inset w-full"
52255319 >
5226 {isGitRepo && (
5227 <div className="pointer-events-auto">
5228 <BranchToolbar
5229 environmentId={activeThread.environmentId}
5230 threadId={activeThread.id}
5231 {...(routeKind === "draft" && draftId ? { draftId } : {})}
5232 onEnvModeChange={onEnvModeChange}
5233 startFromOrigin={startFromOrigin}
5234 onStartFromOriginChange={onStartFromOriginChange}
5235 {...(canOverrideServerThreadEnvMode
5236 ? { effectiveEnvModeOverride: envMode }
5237 : {})}
5238 {...(canOverrideServerThreadEnvMode
5239 ? {
5240 activeThreadBranchOverride: activeThreadBranch,
5241 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
5242 }
5243 : {})}
5244 envLocked={envLocked}
5245 onComposerFocusRequest={scheduleComposerFocus}
5246 {...(canCheckoutPullRequestIntoThread
5247 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
5248 : {})}
5249 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
5250 availableEnvironments={logicalProjectEnvironments}
5251 />
5320 <div className="pointer-events-auto relative z-10 isolate">
5321 {isDraftHeroState ? (
5322 <div className="absolute inset-x-0 bottom-full z-0">
5323 <div
5324 className="pb-8"
5325 style={
5326 forceExpandedMobileComposer
5327 ? {
5328 viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
5329 }
5330 : undefined
5331 }
5332 >
5333 <DraftHeroHeadline
5334 activeProjectRef={activeProjectRef}
5335 activeProjectTitle={activeProject?.title ?? null}
5336 />
5337 </div>
5338 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5339 </div>
5340 ) : (
5341 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5342 )}
5343 <div
5344 className="relative"
5345 style={
5346 forceExpandedMobileComposer
5347 ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }
5348 : undefined
5349 }
5350 >
5351 <div
5352 ref={attachDraftHeroComposerAnchorRef}
5353 className="relative z-10 mx-auto w-full max-w-3xl"
5354 >
5355 <ChatComposer
5356 composerRef={composerRef}
5357 composerDraftTarget={composerDraftTarget}
5358 environmentId={environmentId}
5359 routeKind={routeKind}
5360 routeThreadRef={routeThreadRef}
5361 draftId={draftId}
5362 activeThreadId={activeThreadId}
5363 activeThreadEnvironmentId={activeThread?.environmentId}
5364 activeThread={activeThread}
5365 isServerThread={isServerThread}
5366 isLocalDraftThread={isLocalDraftThread}
5367 forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState}
5368 projectSelectionRequired={isLocalDraftThread && activeProject === null}
5369 phase={phase}
5370 isConnecting={isConnecting}
5371 isSendBusy={isSendBusy}
5372 isPreparingWorktree={isPreparingWorktree}
5373 environmentUnavailable={activeEnvironmentUnavailableState}
5374 activePendingApproval={activePendingApproval}
5375 pendingApprovals={pendingApprovals}
5376 pendingUserInputs={pendingUserInputs}
5377 activePendingProgress={activePendingProgress}
5378 activePendingResolvedAnswers={activePendingResolvedAnswers}
5379 activePendingIsResponding={activePendingIsResponding}
5380 activePendingDraftAnswers={activePendingDraftAnswers}
5381 activePendingQuestionIndex={activePendingQuestionIndex}
5382 respondingRequestIds={respondingRequestIds}
5383 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
5384 activeProposedPlan={activeProposedPlan}
5385 activePlan={activePlan as { turnId?: TurnId } | null}
5386 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
5387 planSidebarLabel={planSidebarLabel}
5388 planSidebarOpen={planSidebarOpen}
5389 runtimeMode={runtimeMode}
5390 interactionMode={interactionMode}
5391 lockedProvider={lockedProvider}
5392 providerStatuses={providerStatuses as ServerProvider[]}
5393 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
5394 activeThreadModelSelection={activeThread?.modelSelection}
5395 activeThreadActivities={activeThread?.activities}
5396 resolvedTheme={resolvedTheme}
5397 settings={settings}
5398 keybindings={keybindings}
5399 terminalOpen={Boolean(terminalUiState.terminalOpen)}
5400 gitCwd={gitCwd}
5401 promptRef={promptRef}
5402 composerImagesRef={composerImagesRef}
5403 composerTerminalContextsRef={composerTerminalContextsRef}
5404 composerElementContextsRef={composerElementContextsRef}
5405 onSend={onSend}
5406 onInterrupt={onInterrupt}
5407 onImplementPlanInNewThread={onImplementPlanInNewThread}
5408 onRespondToApproval={onRespondToApproval}
5409 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
5410 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
5411 onPreviousActivePendingUserInputQuestion={
5412 onPreviousActivePendingUserInputQuestion
5413 }
5414 onChangeActivePendingUserInputCustomAnswer={
5415 onChangeActivePendingUserInputCustomAnswer
5416 }
5417 onProviderModelSelect={onProviderModelSelect}
5418 getModelDisabledReason={getModelDisabledReason}
5419 toggleInteractionMode={toggleInteractionMode}
5420 handleRuntimeModeChange={handleRuntimeModeChange}
5421 handleInteractionModeChange={handleInteractionModeChange}
5422 togglePlanSidebar={togglePlanSidebar}
5423 focusComposer={focusComposer}
5424 scheduleComposerFocus={scheduleComposerFocus}
5425 setThreadError={setThreadError}
5426 onExpandImage={onExpandTimelineImage}
5427 />
5428 </div>
5429 <div
5430 className={cn(
5431 "min-h-0",
5432 isDraftHeroState ? "absolute inset-x-0 top-full" : null,
5433 )}
5434 >
5435 <div
5436 className={cn(
5437 "chat-composer-lower-chrome relative z-10",
5438 isGitRepo
5439 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
5440 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
5441 )}
5442 >
5443 {isGitRepo && (
5444 <div className="pointer-events-auto">
5445 <BranchToolbar
5446 environmentId={activeThread.environmentId}
5447 threadId={activeThread.id}
5448 {...(routeKind === "draft" && draftId ? { draftId } : {})}
5449 onEnvModeChange={onEnvModeChange}
5450 startFromOrigin={startFromOrigin}
5451 onStartFromOriginChange={onStartFromOriginChange}
5452 {...(canOverrideServerThreadEnvMode
5453 ? { effectiveEnvModeOverride: envMode }
5454 : {})}
5455 {...(canOverrideServerThreadEnvMode
5456 ? {
5457 activeThreadBranchOverride: activeThreadBranch,
5458 onActiveThreadBranchOverrideChange:
5459 setPendingServerThreadBranch,
5460 }
5461 : {})}
5462 envLocked={envLocked}
5463 onComposerFocusRequest={scheduleComposerFocus}
5464 {...(canCheckoutPullRequestIntoThread
5465 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
5466 : {})}
5467 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
5468 availableEnvironments={logicalProjectEnvironments}
5469 />
5470 </div>
5471 )}
5472 </div>
5473 </div>
52525474 </div>
5253 )}
5475 </div>
52545476 </div>
52555477 </div>
52565478
apps/web/src/components/Sidebar.logic.test.ts+71
......@@ -19,6 +19,7 @@ import {
1919 resolveThreadStatusPill,
2020 shouldClearThreadSelectionOnMouseDown,
2121 sortProjectsForSidebar,
22 sortScopedProjectsForSidebar,
2223 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2324} from "./Sidebar.logic";
2425import {
......@@ -1066,3 +1067,73 @@ describe("sortProjectsForSidebar", () => {
10661067 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
10671068 });
10681069});
1070
1071describe("sortScopedProjectsForSidebar", () => {
1072 it("keeps identical project ids in different environments separate", () => {
1073 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
1074 const sharedProjectId = ProjectId.make("shared-project");
1075 const projects = [
1076 makeProject({
1077 environmentId: localEnvironmentId,
1078 id: sharedProjectId,
1079 title: "Local project",
1080 }),
1081 makeProject({
1082 environmentId: remoteEnvironmentId,
1083 id: sharedProjectId,
1084 title: "Remote project",
1085 }),
1086 ];
1087 const threads = [
1088 makeThread({
1089 environmentId: localEnvironmentId,
1090 projectId: sharedProjectId,
1091 updatedAt: "2026-03-09T10:02:00.000Z",
1092 }),
1093 makeThread({
1094 environmentId: remoteEnvironmentId,
1095 projectId: sharedProjectId,
1096 updatedAt: "2026-03-09T10:10:00.000Z",
1097 }),
1098 ];
1099
1100 const sorted = sortScopedProjectsForSidebar(projects, threads, "updated_at");
1101
1102 expect(sorted.map((project) => project.title)).toEqual(["Remote project", "Local project"]);
1103 });
1104
1105 it("does not use archived threads as project activity", () => {
1106 const projects = [
1107 makeProject({
1108 id: ProjectId.make("project-visible"),
1109 title: "Visible project",
1110 updatedAt: "2026-03-09T10:01:00.000Z",
1111 }),
1112 makeProject({
1113 id: ProjectId.make("project-archived"),
1114 title: "Archived-only project",
1115 updatedAt: "2026-03-09T10:00:00.000Z",
1116 }),
1117 ];
1118 const threads = [
1119 makeThread({
1120 id: ThreadId.make("thread-visible"),
1121 projectId: ProjectId.make("project-visible"),
1122 updatedAt: "2026-03-09T10:02:00.000Z",
1123 }),
1124 makeThread({
1125 id: ThreadId.make("thread-archived"),
1126 projectId: ProjectId.make("project-archived"),
1127 updatedAt: "2026-03-09T10:10:00.000Z",
1128 archivedAt: "2026-03-09T10:11:00.000Z",
1129 }),
1130 ];
1131
1132 const sorted = sortScopedProjectsForSidebar(projects, threads, "updated_at");
1133
1134 expect(sorted.map((project) => project.title)).toEqual([
1135 "Visible project",
1136 "Archived-only project",
1137 ]);
1138 });
1139});
apps/web/src/components/Sidebar.logic.ts+72-20
......@@ -24,6 +24,16 @@ type SidebarProject = {
2424 updatedAt?: string | undefined;
2525};
2626
27type ScopedSidebarProject = SidebarProject & {
28 environmentId: string;
29};
30
31type ScopedSidebarThread = ThreadSortInput & {
32 environmentId: string;
33 projectId: string;
34 archivedAt: string | null;
35};
36
2737export type ThreadTraversalDirection = "previous" | "next";
2838
2939export interface ThreadStatusPill {
......@@ -534,6 +544,25 @@ export function getProjectSortTimestamp(
534544 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
535545}
536546
547function sortProjectsByActivity<TProject extends SidebarProject>(
548 projects: readonly TProject[],
549 sortOrder: SidebarProjectSortOrder,
550 getProjectThreads: (project: TProject) => readonly ThreadSortInput[],
551 compareTies: (left: TProject, right: TProject) => number,
552): TProject[] {
553 if (sortOrder === "manual") {
554 return [...projects];
555 }
556
557 return [...projects].toSorted((left, right) => {
558 const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder);
559 const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder);
560 const byTimestamp =
561 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
562 return byTimestamp || compareTies(left, right);
563 });
564}
565
537566export function sortProjectsForSidebar<
538567 TProject extends SidebarProject,
539568 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
......@@ -542,10 +571,6 @@ export function sortProjectsForSidebar<
542571 threads: readonly TThread[],
543572 sortOrder: SidebarProjectSortOrder,
544573): TProject[] {
545 if (sortOrder === "manual") {
546 return [...projects];
547 }
548
549574 const threadsByProjectId = new Map<string, TThread[]>();
550575 for (const thread of threads) {
551576 const existing = threadsByProjectId.get(thread.projectId) ?? [];
......@@ -553,20 +578,47 @@ export function sortProjectsForSidebar<
553578 threadsByProjectId.set(thread.projectId, existing);
554579 }
555580
556 return [...projects].toSorted((left, right) => {
557 const rightTimestamp = getProjectSortTimestamp(
558 right,
559 threadsByProjectId.get(right.id) ?? [],
560 sortOrder,
561 );
562 const leftTimestamp = getProjectSortTimestamp(
563 left,
564 threadsByProjectId.get(left.id) ?? [],
565 sortOrder,
566 );
567 const byTimestamp =
568 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
569 if (byTimestamp !== 0) return byTimestamp;
570 return left.title.localeCompare(right.title) || left.id.localeCompare(right.id);
571 });
581 return sortProjectsByActivity(
582 projects,
583 sortOrder,
584 (project) => threadsByProjectId.get(project.id) ?? [],
585 (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id),
586 );
587}
588
589/**
590 * Sorts the cross-environment project collection used by landing surfaces.
591 * Project ids are only unique within an environment, and archived threads
592 * must not make a project appear recently active.
593 */
594export function sortScopedProjectsForSidebar<
595 TProject extends ScopedSidebarProject,
596 TThread extends ScopedSidebarThread,
597>(
598 projects: readonly TProject[],
599 threads: readonly TThread[],
600 sortOrder: SidebarProjectSortOrder,
601): TProject[] {
602 const scopedKey = (environmentId: string, projectId: string) =>
603 `${environmentId}\u0000${projectId}`;
604 const threadsByProject = new Map<string, TThread[]>();
605 for (const thread of threads) {
606 if (thread.archivedAt !== null) {
607 continue;
608 }
609 const key = scopedKey(thread.environmentId, thread.projectId);
610 const existing = threadsByProject.get(key) ?? [];
611 existing.push(thread);
612 threadsByProject.set(key, existing);
613 }
614
615 return sortProjectsByActivity(
616 projects,
617 sortOrder,
618 (project) => threadsByProject.get(scopedKey(project.environmentId, project.id)) ?? [],
619 (left, right) =>
620 left.title.localeCompare(right.title) ||
621 left.environmentId.localeCompare(right.environmentId) ||
622 left.id.localeCompare(right.id),
623 );
572624}
apps/web/src/components/chat/ChatComposer.tsx+34-14
......@@ -442,6 +442,8 @@ export interface ChatComposerProps {
442442 activeThread: Thread | undefined;
443443 isServerThread: boolean;
444444 isLocalDraftThread: boolean;
445 forceExpandedOnMobile: boolean;
446 projectSelectionRequired: boolean;
445447
446448 // Session phase
447449 phase: SessionPhase;
......@@ -553,6 +555,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
553555 activeThread,
554556 isServerThread: _isServerThread,
555557 isLocalDraftThread: _isLocalDraftThread,
558 forceExpandedOnMobile,
559 projectSelectionRequired,
556560 phase,
557561 isConnecting,
558562 isSendBusy,
......@@ -883,7 +887,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
883887 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
884888 const [isComposerFocused, setIsComposerFocused] = useState(false);
885889 const isMobileViewport = useMediaQuery("max-sm");
886 const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
890 const isComposerCollapsedMobile =
891 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
887892
888893 // ------------------------------------------------------------------
889894 // Refs
......@@ -1136,7 +1141,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11361141 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
11371142 );
11381143 const collapsedComposerPrimaryActionDisabled =
1139 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1144 phase === "running" ||
1145 isSendBusy ||
1146 isConnecting ||
1147 projectSelectionRequired ||
1148 !composerSendState.hasSendableContent;
11401149 const collapsedComposerPrimaryActionLabel = "Send message";
11411150 const showMobilePendingAnswerActions =
11421151 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
......@@ -1928,6 +1937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19281937 isConnecting ||
19291938 isComposerApprovalState ||
19301939 pendingUserInputs.length > 0 ||
1940 projectSelectionRequired ||
19311941 (environmentUnavailable !== null && activePendingProgress === null)
19321942 ) {
19331943 return false;
......@@ -2028,6 +2038,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20282038 isConnecting,
20292039 isComposerApprovalState,
20302040 pendingUserInputs.length,
2041 projectSelectionRequired,
20312042 environmentUnavailable,
20322043 activePendingProgress,
20332044 applyPromptReplacement,
......@@ -2065,9 +2076,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20652076 ref={composerSurfaceRef}
20662077 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
20672078 className={cn(
2068 "chat-composer-glass rounded-[20px] border transition-colors duration-200 has-focus-visible:border-ring/45",
2079 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45",
20692080 isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",
2070 environmentUnavailable ? "opacity-75" : null,
2081 environmentUnavailable || projectSelectionRequired ? "opacity-75" : null,
20712082 composerProviderState.composerSurfaceClassName,
20722083 )}
20732084 onFocusCapture={(event) => {
......@@ -2179,7 +2190,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21792190 promptHasText={false}
21802191 isSendBusy={isSendBusy}
21812192 isConnecting={isConnecting}
2182 isEnvironmentUnavailable={environmentUnavailable !== null}
2193 isEnvironmentUnavailable={
2194 environmentUnavailable !== null || projectSelectionRequired
2195 }
21832196 isPreparingWorktree={false}
21842197 hasSendableContent={false}
21852198 preserveComposerFocusOnPointerDown
......@@ -2413,17 +2426,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24132426 ? "Type your own answer, or leave this blank to use the selected option"
24142427 : showPlanFollowUpPrompt && activeProposedPlan
24152428 ? "Add feedback to refine the plan, or leave this blank to implement it"
2416 : environmentUnavailable
2417 ? `${environmentUnavailable.label}: ${connectionStatusText(
2418 environmentUnavailable.connection,
2419 )}`
2420 : phase === "disconnected"
2421 ? "Ask for follow-up changes or attach images"
2422 : "Ask anything, @tag files/folders, $use skills, or / for commands"
2429 : projectSelectionRequired
2430 ? "Choose a project above to start a thread"
2431 : environmentUnavailable
2432 ? `${environmentUnavailable.label}: ${connectionStatusText(
2433 environmentUnavailable.connection,
2434 )}`
2435 : phase === "disconnected"
2436 ? "Ask for follow-up changes or attach images"
2437 : "Ask anything, @tag files/folders, $use skills, or / for commands"
24232438 }
24242439 disabled={
24252440 isConnecting ||
24262441 isComposerApprovalState ||
2442 projectSelectionRequired ||
24272443 (environmentUnavailable !== null && activePendingProgress === null)
24282444 }
24292445 />
......@@ -2440,7 +2456,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24402456 promptHasText={false}
24412457 isSendBusy={isSendBusy}
24422458 isConnecting={isConnecting}
2443 isEnvironmentUnavailable={environmentUnavailable !== null}
2459 isEnvironmentUnavailable={
2460 environmentUnavailable !== null || projectSelectionRequired
2461 }
24442462 isPreparingWorktree={false}
24452463 hasSendableContent={false}
24462464 preserveComposerFocusOnPointerDown
......@@ -2551,7 +2569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25512569 promptHasText={prompt.trim().length > 0}
25522570 isSendBusy={isSendBusy}
25532571 isConnecting={isConnecting}
2554 isEnvironmentUnavailable={environmentUnavailable !== null}
2572 isEnvironmentUnavailable={
2573 environmentUnavailable !== null || projectSelectionRequired
2574 }
25552575 isPreparingWorktree={isPreparingWorktree}
25562576 hasSendableContent={composerSendState.hasSendableContent}
25572577 preserveComposerFocusOnPointerDown={isMobileViewport}
apps/web/src/components/chat/DraftHeroHeadline.tsx created+114
......@@ -0,0 +1,114 @@
1import type { ScopedProjectRef } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { FolderPlusIcon } from "lucide-react";
4import { useMemo } from "react";
5
6import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext";
7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
8import { useProjects, useThreadShells } from "~/state/entities";
9import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
10import {
11 Menu,
12 MenuItem,
13 MenuPopup,
14 MenuRadioGroup,
15 MenuRadioItem,
16 MenuSeparator,
17 MenuTrigger,
18} from "../ui/menu";
19
20interface DraftHeroHeadlineProps {
21 readonly activeProjectRef: ScopedProjectRef | null;
22 readonly activeProjectTitle: string | null;
23}
24
25export function DraftHeroHeadline({
26 activeProjectRef,
27 activeProjectTitle,
28}: DraftHeroHeadlineProps) {
29 const projects = useProjects();
30 const threads = useThreadShells();
31 const handleNewThread = useNewThreadHandler();
32 const openAddProject = useOpenAddProjectCommandPalette();
33
34 const orderedProjects = useMemo(
35 () => sortScopedProjectsForSidebar(projects, threads, "updated_at"),
36 [projects, threads],
37 );
38 const projectByKey = useMemo(
39 () =>
40 new Map(
41 orderedProjects.map(
42 (project) =>
43 [
44 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
45 project,
46 ] as const,
47 ),
48 ),
49 [orderedProjects],
50 );
51 const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef);
52 const hasResolvedProject = activeProjectTitle !== null;
53 const canChooseProject = orderedProjects.length > 0;
54 const shouldShowProjectMenu = canChooseProject;
55
56 const projectSelector = shouldShowProjectMenu ? (
57 <Menu>
58 <MenuTrigger
59 aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
60 className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
61 >
62 {activeProjectTitle ?? "Choose a project"}
63 </MenuTrigger>
64 <MenuPopup align="center" className="max-h-80 w-64 overflow-y-auto">
65 <MenuRadioGroup
66 value={activeProjectKey}
67 onValueChange={(value) => {
68 const project = projectByKey.get(value as string);
69 if (!project || value === activeProjectKey) {
70 return;
71 }
72 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
73 replace: true,
74 });
75 }}
76 >
77 {orderedProjects.map((project) => {
78 const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
79 return (
80 <MenuRadioItem key={key} value={key} closeOnClick>
81 <span className="min-w-0 truncate">{project.title}</span>
82 </MenuRadioItem>
83 );
84 })}
85 </MenuRadioGroup>
86 <MenuSeparator />
87 <MenuItem onClick={openAddProject}>
88 <FolderPlusIcon />
89 New project
90 </MenuItem>
91 </MenuPopup>
92 </Menu>
93 ) : (
94 <button
95 type="button"
96 onClick={openAddProject}
97 className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-muted-foreground/60 underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
98 >
99 {activeProjectTitle ?? "Add a project"}
100 </button>
101 );
102
103 return (
104 <h1 className="mx-auto w-full max-w-5xl text-center font-normal text-3xl text-foreground tracking-tight sm:text-5xl">
105 {hasResolvedProject ? (
106 <>What should we build in {projectSelector}?</>
107 ) : canChooseProject ? (
108 <>{projectSelector} to start</>
109 ) : (
110 <>Add a project to start</>
111 )}
112 </h1>
113 );
114}
apps/web/src/components/chat/MessagesTimeline.tsx+5
......@@ -179,6 +179,7 @@ interface MessagesTimelineProps {
179179 contentInsetEndAdjustment: number;
180180 onIsAtEndChange: (isAtEnd: boolean) => void;
181181 onManualNavigation: () => void;
182 hideEmptyPlaceholder?: boolean;
182183}
183184
184185// ---------------------------------------------------------------------------
......@@ -212,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
212213 contentInsetEndAdjustment,
213214 onIsAtEndChange,
214215 onManualNavigation,
216 hideEmptyPlaceholder = false,
215217}: MessagesTimelineProps) {
216218 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
217219 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
......@@ -458,6 +460,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
458460 );
459461
460462 if (rows.length === 0 && !isWorking) {
463 if (hideEmptyPlaceholder) {
464 return null;
465 }
461466 return (
462467 <div className="flex h-full items-center justify-center">
463468 <p className="text-sm text-muted-foreground/30">
apps/web/src/components/chat/draftHeroTransition.test.ts created+123
......@@ -0,0 +1,123 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 DRAFT_HERO_TRANSITION_ANIMATION_ID,
5 runMobileComposerTransition,
6 waitForDraftHeroTransition,
7} from "./draftHeroTransition";
8
9afterEach(() => {
10 vi.unstubAllGlobals();
11});
12
13describe("waitForDraftHeroTransition", () => {
14 it("waits for active draft hero animations and ignores unrelated animations", async () => {
15 let finishTransition: (() => void) | undefined;
16 const transitionFinished = new Promise<void>((resolve) => {
17 finishTransition = resolve;
18 });
19 vi.stubGlobal("document", {
20 getAnimations: () => [
21 { id: "unrelated-animation", finished: new Promise<void>(() => undefined) },
22 { id: DRAFT_HERO_TRANSITION_ANIMATION_ID, finished: transitionFinished },
23 ],
24 });
25
26 let handoffComplete = false;
27 const handoff = waitForDraftHeroTransition().then(() => {
28 handoffComplete = true;
29 });
30 await Promise.resolve();
31 expect(handoffComplete).toBe(false);
32
33 finishTransition?.();
34 await handoff;
35 expect(handoffComplete).toBe(true);
36 });
37
38 it("allows the handoff when an active transition is cancelled", async () => {
39 vi.stubGlobal("document", {
40 getAnimations: () => [
41 {
42 id: DRAFT_HERO_TRANSITION_ANIMATION_ID,
43 finished: Promise.reject(new Error("cancelled")),
44 },
45 ],
46 });
47
48 await expect(waitForDraftHeroTransition()).resolves.toBeUndefined();
49 });
50});
51
52describe("runMobileComposerTransition", () => {
53 it("keeps the route handoff waiting while the mobile composer morph is active", async () => {
54 let finishTransition: (() => void) | undefined;
55 const transitionFinished = new Promise<void>((resolve) => {
56 finishTransition = resolve;
57 });
58 const dataset: Record<string, string> = {};
59 vi.stubGlobal("document", {
60 documentElement: { dataset },
61 getAnimations: () => [],
62 startViewTransition: (update: () => void | Promise<void>) => {
63 void update();
64 return { finished: transitionFinished };
65 },
66 });
67 vi.stubGlobal("window", {
68 matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
69 });
70
71 const transition = runMobileComposerTransition(() => undefined);
72 await Promise.resolve();
73
74 let handoffComplete = false;
75 const handoff = waitForDraftHeroTransition().then(() => {
76 handoffComplete = true;
77 });
78 await Promise.resolve();
79 expect(handoffComplete).toBe(false);
80
81 finishTransition?.();
82 await Promise.all([transition, handoff]);
83 expect(handoffComplete).toBe(true);
84 });
85
86 it("uses a scoped view transition on mobile", async () => {
87 const dataset: Record<string, string> = {};
88 const startViewTransition = vi.fn((update: () => void | Promise<void>) => ({
89 finished: Promise.resolve(update()).then(() => undefined),
90 }));
91 vi.stubGlobal("document", {
92 documentElement: { dataset },
93 startViewTransition,
94 });
95 vi.stubGlobal("window", {
96 matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
97 });
98 const update = vi.fn();
99
100 await runMobileComposerTransition(update);
101
102 expect(startViewTransition).toHaveBeenCalledOnce();
103 expect(update).toHaveBeenCalledOnce();
104 expect(dataset).not.toHaveProperty("mobileComposerRouteTransition");
105 });
106
107 it("updates without a view transition when reduced motion is preferred", async () => {
108 const startViewTransition = vi.fn();
109 vi.stubGlobal("document", {
110 documentElement: { dataset: {} },
111 startViewTransition,
112 });
113 vi.stubGlobal("window", {
114 matchMedia: () => ({ matches: true }),
115 });
116 const update = vi.fn();
117
118 await runMobileComposerTransition(update);
119
120 expect(startViewTransition).not.toHaveBeenCalled();
121 expect(update).toHaveBeenCalledOnce();
122 });
123});
apps/web/src/components/chat/draftHeroTransition.ts created+82
......@@ -0,0 +1,82 @@
1export const DRAFT_HERO_TRANSITION_ANIMATION_ID = "t3-draft-hero-transition";
2export const DRAFT_HERO_TRANSITION_DURATION_MS = 180;
3export const DRAFT_HERO_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
4export const MOBILE_COMPOSER_VIEW_TRANSITION_NAME = "t3-mobile-composer";
5export const MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME = "t3-mobile-draft-headline";
6
7type ComposerViewTransition = {
8 readonly finished: Promise<void>;
9};
10
11let activeMobileComposerTransition: Promise<void> | null = null;
12
13type ComposerViewTransitionDocument = Document & {
14 startViewTransition?: (update: () => void | Promise<void>) => ComposerViewTransition;
15};
16
17export async function waitForDraftHeroTransition(): Promise<void> {
18 const mobileComposerTransition = activeMobileComposerTransition;
19 if (typeof document === "undefined" || typeof document.getAnimations !== "function") {
20 await mobileComposerTransition;
21 return;
22 }
23
24 const activeTransitions = document
25 .getAnimations()
26 .filter((animation) => animation.id === DRAFT_HERO_TRANSITION_ANIMATION_ID);
27
28 await Promise.all([
29 mobileComposerTransition,
30 ...activeTransitions.map(async (animation) => {
31 try {
32 await animation.finished;
33 } catch {
34 // A cancelled transition is already safe to hand off.
35 }
36 }),
37 ]);
38}
39
40export async function runMobileComposerTransition(
41 update: () => void | Promise<void>,
42): Promise<void> {
43 if (typeof document === "undefined" || typeof window === "undefined") {
44 await update();
45 return;
46 }
47
48 const transitionDocument = document as ComposerViewTransitionDocument;
49 const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false;
50 const prefersReducedMotion =
51 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
52 if (!mobileViewport || prefersReducedMotion || !transitionDocument.startViewTransition) {
53 await update();
54 return;
55 }
56
57 let updateStarted = false;
58 const runUpdate = async () => {
59 if (updateStarted) return;
60 updateStarted = true;
61 await update();
62 };
63 let transitionFinished: Promise<void> | null = null;
64 transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true";
65 try {
66 const transition = transitionDocument.startViewTransition(runUpdate);
67 transitionFinished = transition.finished.catch(() => undefined);
68 activeMobileComposerTransition = transitionFinished;
69 try {
70 await transition.finished;
71 } catch {
72 await runUpdate();
73 }
74 } catch {
75 await runUpdate();
76 } finally {
77 if (activeMobileComposerTransition === transitionFinished) {
78 activeMobileComposerTransition = null;
79 }
80 delete transitionDocument.documentElement.dataset.mobileComposerRouteTransition;
81 }
82}
apps/web/src/hooks/useHandleNewThread.ts+3
......@@ -47,6 +47,7 @@ export function useNewThreadHandler() {
4747 worktreePath?: string | null;
4848 envMode?: DraftThreadEnvMode;
4949 startFromOrigin?: boolean;
50 replace?: boolean;
5051 },
5152 ): Promise<void> => {
5253 const {
......@@ -120,6 +121,7 @@ export function useNewThreadHandler() {
120121 await router.navigate({
121122 to: "/draft/$draftId",
122123 params: { draftId: reusableStoredDraftThread.draftId },
124 replace: options?.replace ?? false,
123125 });
124126 })();
125127 }
......@@ -180,6 +182,7 @@ export function useNewThreadHandler() {
180182 await router.navigate({
181183 to: "/draft/$draftId",
182184 params: { draftId },
185 replace: options?.replace ?? false,
183186 });
184187 })();
185188 },
apps/web/src/index.css+71
......@@ -4,6 +4,77 @@
44/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
55@custom-variant wco (&:is(.wco, .wco *));
66
7/* On mobile, morph the expanded hero composer into the compact docked
8 composer while it moves; the rest of the app remains visually stationary. */
9html[data-mobile-composer-route-transition="true"]::view-transition-old(root),
10html[data-mobile-composer-route-transition="true"]::view-transition-new(root) {
11 animation: none;
12}
13
14html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-composer) {
15 animation-duration: 180ms;
16 animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
17}
18
19html[data-mobile-composer-route-transition="true"]::view-transition-image-pair(t3-mobile-composer) {
20 isolation: isolate;
21}
22
23html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-composer) {
24 animation: t3-mobile-composer-old 180ms linear both;
25 mix-blend-mode: normal;
26}
27
28html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobile-composer) {
29 animation: t3-mobile-composer-new 180ms linear both;
30 mix-blend-mode: normal;
31}
32
33/* The expanded and compact composers have different internal layouts. Use a
34 brief crossfade while their shared wrapper moves so the layout change does
35 not read as a single-frame cut. */
36@keyframes t3-mobile-composer-old {
37 0%,
38 35% {
39 opacity: 1;
40 }
41 65%,
42 100% {
43 opacity: 0;
44 }
45}
46
47@keyframes t3-mobile-composer-new {
48 0%,
49 35% {
50 opacity: 0;
51 }
52 65%,
53 100% {
54 opacity: 1;
55 }
56}
57
58html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) {
59 animation-duration: 130ms;
60}
61
62html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-draft-headline) {
63 animation: t3-mobile-draft-headline-exit 130ms cubic-bezier(0.4, 0, 1, 1) both;
64 mix-blend-mode: normal;
65}
66
67@keyframes t3-mobile-draft-headline-exit {
68 from {
69 opacity: 1;
70 transform: translateY(0);
71 }
72 to {
73 opacity: 0;
74 transform: translateY(-6px);
75 }
76}
77
778:root {
879 --app-scrollbar-width: 6px;
980 --desktop-window-right-resize-inset: 0px;
apps/web/src/routes/_chat.draft.$draftId.tsx+17-16
......@@ -8,6 +8,7 @@ import {
88 useComposerDraftStore,
99} from "../composerDraftStore";
1010import { SidebarInset } from "../components/ui/sidebar";
11import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition";
1112import { buildThreadRouteParams } from "../threadRoutes";
1213import { useThread, useThreadRefs } from "../state/entities";
1314
......@@ -40,11 +41,22 @@ function DraftChatThreadRouteView() {
4041 if (!canonicalThreadRef) {
4142 return;
4243 }
43 void navigate({
44 to: "/$environmentId/$threadId",
45 params: buildThreadRouteParams(canonicalThreadRef),
46 replace: true,
44
45 let cancelled = false;
46 void waitForDraftHeroTransition().then(() => {
47 if (cancelled) {
48 return;
49 }
50 void navigate({
51 to: "/$environmentId/$threadId",
52 params: buildThreadRouteParams(canonicalThreadRef),
53 replace: true,
54 });
4755 });
56
57 return () => {
58 cancelled = true;
59 };
4860 }, [canonicalThreadRef, navigate]);
4961
5062 useEffect(() => {
......@@ -54,18 +66,6 @@ function DraftChatThreadRouteView() {
5466 void navigate({ to: "/", replace: true });
5567 }, [canonicalThreadRef, draftSession, navigate]);
5668
57 if (canonicalThreadRef) {
58 return (
59 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
60 <ChatView
61 environmentId={canonicalThreadRef.environmentId}
62 threadId={canonicalThreadRef.threadId}
63 routeKind="server"
64 />
65 </SidebarInset>
66 );
67 }
68
6969 if (!draftSession) {
7070 return null;
7171 }
......@@ -77,6 +77,7 @@ function DraftChatThreadRouteView() {
7777 environmentId={draftSession.environmentId}
7878 threadId={draftSession.threadId}
7979 routeKind="draft"
80 forceExpandedMobileComposer
8081 />
8182 </SidebarInset>
8283 );
apps/web/src/routes/_chat.index.tsx+114-3
......@@ -1,10 +1,19 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
12import { createFileRoute, Link } from "@tanstack/react-router";
2import { LinkIcon, PlusIcon } from "lucide-react";
3import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
35
4import { NoActiveThreadState } from "../components/NoActiveThreadState";
6import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
7import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
58import { Button } from "../components/ui/button";
69import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
710import { SidebarInset } from "../components/ui/sidebar";
11import { useNewThreadHandler } from "../hooks/useHandleNewThread";
12import {
13 useAllEnvironmentShellsBootstrapped,
14 useProjects,
15 useThreadShells,
16} from "../state/entities";
817import { useEnvironments } from "../state/environments";
918import { APP_DISPLAY_NAME } from "~/branding";
1019import { hasCloudPublicConfig } from "~/cloud/publicConfig";
......@@ -19,7 +28,109 @@ function ChatIndexRouteView() {
1928 return <HostedStaticOnboardingState />;
2029 }
2130
22 return <NoActiveThreadState />;
31 return <IndexDraftLanding />;
32}
33
34/**
35 * Landing on the index route drops straight into a draft thread for the most
36 * recently active project, so the first screen is a prompt instead of a dead
37 * end. Falls back to an add-project hero when no project exists yet.
38 */
39function IndexDraftLanding() {
40 const projects = useProjects();
41 const threads = useThreadShells();
42 const bootstrapped = useAllEnvironmentShellsBootstrapped();
43 const handleNewThread = useNewThreadHandler();
44 const startingRef = useRef(false);
45 const [startState, setStartState] = useState({ failed: false, retryRequest: 0 });
46
47 const mostRecentProject = useMemo(
48 () =>
49 bootstrapped
50 ? (sortScopedProjectsForSidebar(projects, threads, "updated_at")[0] ?? null)
51 : null,
52 [bootstrapped, projects, threads],
53 );
54
55 useEffect(() => {
56 if (mostRecentProject === null || startingRef.current) {
57 return;
58 }
59 startingRef.current = true;
60 void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), {
61 replace: true,
62 }).catch(() => {
63 startingRef.current = false;
64 setStartState((state) => ({ ...state, failed: true }));
65 });
66 }, [handleNewThread, mostRecentProject, startState.retryRequest]);
67
68 if (!bootstrapped) {
69 return null;
70 }
71 if (mostRecentProject !== null) {
72 return startState.failed ? (
73 <DraftStartError
74 onRetry={() => {
75 setStartState((state) => ({
76 failed: false,
77 retryRequest: state.retryRequest + 1,
78 }));
79 }}
80 />
81 ) : null;
82 }
83 return <NoProjectsHero />;
84}
85
86function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
87 return (
88 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
89 <Empty className="flex-1">
90 <EmptyHeader className="max-w-md">
91 <EmptyTitle className="text-foreground text-xl">Couldn’t start a new thread</EmptyTitle>
92 <EmptyDescription className="mt-2 text-sm text-muted-foreground/78">
93 The project is still available. Try opening the draft again.
94 </EmptyDescription>
95 <div className="mt-5 flex justify-center">
96 <Button size="sm" onClick={onRetry}>
97 <RotateCcwIcon className="size-4" />
98 Try again
99 </Button>
100 </div>
101 </EmptyHeader>
102 </Empty>
103 </SidebarInset>
104 );
105}
106
107function NoProjectsHero() {
108 const openAddProject = useOpenAddProjectCommandPalette();
109
110 return (
111 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
112 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
113 <Empty className="flex-1">
114 <div className="w-full max-w-lg px-8 py-12">
115 <EmptyHeader className="max-w-none">
116 <EmptyTitle className="text-foreground text-2xl sm:text-3xl">
117 What should we work on?
118 </EmptyTitle>
119 <EmptyDescription className="mt-2 text-sm text-muted-foreground/78">
120 Add a project to start your first thread.
121 </EmptyDescription>
122 <div className="mt-6 flex justify-center">
123 <Button size="sm" onClick={openAddProject}>
124 <PlusIcon className="size-4" />
125 Add project
126 </Button>
127 </div>
128 </EmptyHeader>
129 </div>
130 </Empty>
131 </div>
132 </SidebarInset>
133 );
23134}
24135
25136export const Route = createFileRoute("/_chat/")({
apps/web/src/state/entities.ts+5
......@@ -20,6 +20,7 @@ import { useMemo } from "react";
2020import { appAtomRegistry } from "../rpc/atomRegistry";
2121import { environmentProjects } from "./projects";
2222import { environmentServerConfigsAtom } from "./server";
23import { allEnvironmentShellsBootstrappedAtom } from "./shell";
2324import { environmentThreadDetails, environmentThreadShells } from "./threads";
2425
2526const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
......@@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
113114 return useAtomValue(environmentThreadShells.threadShellsAtom);
114115}
115116
117export function useAllEnvironmentShellsBootstrapped(): boolean {
118 return useAtomValue(allEnvironmentShellsBootstrappedAtom);
119}
120
116121export function useThreadShellsForProjectRefs(
117122 refs: ReadonlyArray<ScopedProjectRef>,
118123): ReadonlyArray<EnvironmentThreadShell> {
apps/web/src/state/shell.ts+31
......@@ -1,9 +1,15 @@
1import {
2 AVAILABLE_CONNECTION_STATE,
3 connectionProjectionPhase,
4} from "@t3tools/client-runtime/connection";
15import {
26 createEnvironmentShellAtoms,
37 createEnvironmentShellSummaryAtom,
48 createEnvironmentSnapshotAtom,
59 createShellEnvironmentAtoms,
610} from "@t3tools/client-runtime/state/shell";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";
713
814import { environmentCatalog } from "../connection/catalog";
915import { connectionAtomRuntime } from "../connection/runtime";
......@@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
1521 catalogValueAtom: environmentCatalog.catalogValueAtom,
1622 shellStateValueAtom: environmentShell.stateValueAtom,
1723});
24
25export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
26 const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom));
27 if (Option.isNone(catalog)) {
28 return false;
29 }
30 for (const environmentId of catalog.value.entries.keys()) {
31 if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
32 continue;
33 }
34 const connection = Option.getOrElse(
35 AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))),
36 () => AVAILABLE_CONNECTION_STATE,
37 );
38 if (connectionProjectionPhase(connection) !== "disconnected") {
39 return false;
40 }
41 // A retrying environment is only transiently disconnected; give it its
42 // first retries before letting the landing settle without its snapshot.
43 if (connection.phase === "backoff" && connection.desired && connection.attempt <= 2) {
44 return false;
45 }
46 }
47 return true;
48}).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped"));