History

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

Yordis Prieto committed 3 months ago

Commit 1bd2fa21 parent 5ca326615 files · +1102 −193Browse files

Unified · Split

15 files
  1. apps/web/src/components/AppSidebarLayout.tsx +3 −5
  2. apps/web/src/components/ChatView.tsx +357 −135
  3. apps/web/src/components/Sidebar.logic.test.ts +71 −0
  4. apps/web/src/components/Sidebar.logic.ts +72 −20
  5. apps/web/src/components/chat/ChatComposer.tsx +34 −14
  6. apps/web/src/components/chat/DraftHeroHeadline.tsx +114 −0
  7. apps/web/src/components/chat/MessagesTimeline.tsx +5 −0
  8. apps/web/src/components/chat/draftHeroTransition.test.ts +123 −0
  9. apps/web/src/components/chat/draftHeroTransition.ts +82 −0
  10. apps/web/src/hooks/useHandleNewThread.ts +3 −0
  11. apps/web/src/index.css +71 −0
  12. apps/web/src/routes/_chat.draft.$draftId.tsx +17 −16
  13. apps/web/src/routes/_chat.index.tsx +114 −3
  14. apps/web/src/state/entities.ts +5 −0
  15. apps/web/src/state/shell.ts +31 −0
apps/web/src/components/AppSidebarLayout.tsx+3−5
@@ -1,5 +1,5 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
33 import { useLocation, useNavigate } from "@tanstack/react-router";
44
55 import { isElectron } from "../env";
@@ -56,8 +56,6 @@ function SidebarControl() { · 50 unmodified lines
5656 export 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 }) { · 31 unmodified lines
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 }) { · 3 unmodified lines
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 { · 52 unmodified lines
5353 useRef,
5454 useState,
5555 } from "react";
56+import { flushSync } from "react-dom";
5657 import { useNavigate } from "@tanstack/react-router";
5758 import { useShallow } from "zustand/react/shallow";
5859 import {
@@ -201,6 +202,7 @@ import { · 142 unmodified lines
201202 } from "../state/entities";
202203 import { environmentShell } from "../state/shell";
203204 import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
205+import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
204206 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
205207 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
206208 import { MessagesTimeline } from "./chat/MessagesTimeline";
@@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; · 5 unmodified lines
212214 import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
213215 import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
214216 import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
217+import {
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";
215225 import {
216226 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
217227 buildExpiredTerminalContextToastCopy,
@@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; · 38 unmodified lines
256266 const EMPTY_PROVIDERS: ServerProvider[] = [];
257267 const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
258268 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
269+function 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+}
259341 const PreviewPanel = lazy(() =>
260342 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
261343 );
@@ -339,6 +421,7 @@ type ChatViewProps = · 77 unmodified lines
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 = · 2 unmodified lines
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 · 626 unmodified lines
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.
1069+type LocalThreadErrorEntry = {
1070+ readonly message: string | null;
1071+ readonly at: number;
1072+};
1073+
9821074 function ChatViewContent(props: ChatViewProps) {
9831075 const {
9841076 environmentId,
@@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
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) { · 43 unmodified lines
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) { · 58 unmodified lines
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) { · 105 unmodified lines
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) { · 8 unmodified lines
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) { · 819 unmodified lines
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) { · 157 unmodified lines
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) { · 1 unmodified line
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) { · 1706 unmodified lines
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) { · 11 unmodified lines
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) { · 247 unmodified lines
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) { · 861 unmodified lines
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) { · 16 unmodified lines
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−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 resolveThreadStatusPill,
2020 shouldClearThreadSelectionOnMouseDown,
2121 sortProjectsForSidebar,
22+ sortScopedProjectsForSidebar,
2223 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2324 } from "./Sidebar.logic";
2425 import {
@@ -1066,3 +1067,73 @@ describe("sortProjectsForSidebar", () => { · 1041 unmodified lines
10661067 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
10671068 });
10681069 });
1070+
1071+describe("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 = { · 23 unmodified lines
2424 updatedAt?: string | undefined;
2525 };
2626
27+type ScopedSidebarProject = SidebarProject & {
28+ environmentId: string;
29+};
30+
31+type ScopedSidebarThread = ThreadSortInput & {
32+ environmentId: string;
33+ projectId: string;
34+ archivedAt: string | null;
35+};
36+
2737 export type ThreadTraversalDirection = "previous" | "next";
2838
2939 export interface ThreadStatusPill {
@@ -534,6 +544,25 @@ export function getProjectSortTimestamp( · 504 unmodified lines
534544 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
535545 }
536546
547+function 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+
537566 export function sortProjectsForSidebar<
538567 TProject extends SidebarProject,
539568 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
@@ -542,10 +571,6 @@ export function sortProjectsForSidebar< · 2 unmodified lines
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< · 1 unmodified line
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+ */
594+export 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 { · 441 unmodified lines
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) · 105 unmodified lines
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) · 324 unmodified lines
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) · 246 unmodified lines
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) · 785 unmodified lines
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) · 94 unmodified lines
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) · 31 unmodified lines
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) · 105 unmodified lines
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) · 227 unmodified lines
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) · 10 unmodified lines
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) · 104 unmodified lines
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+114−0
@@ -0,0 +1,114 @@
1+import type { ScopedProjectRef } from "@t3tools/contracts";
2+import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3+import { FolderPlusIcon } from "lucide-react";
4+import { useMemo } from "react";
5+
6+import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext";
7+import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
8+import { useProjects, useThreadShells } from "~/state/entities";
9+import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
10+import {
11+ Menu,
12+ MenuItem,
13+ MenuPopup,
14+ MenuRadioGroup,
15+ MenuRadioItem,
16+ MenuSeparator,
17+ MenuTrigger,
18+} from "../ui/menu";
19+
20+interface DraftHeroHeadlineProps {
21+ readonly activeProjectRef: ScopedProjectRef | null;
22+ readonly activeProjectTitle: string | null;
23+}
24+
25+export 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−0
@@ -179,6 +179,7 @@ interface MessagesTimelineProps { · 178 unmodified lines
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({ · 27 unmodified lines
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({ · 240 unmodified lines
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+123−0
@@ -0,0 +1,123 @@
1+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+import {
4+ DRAFT_HERO_TRANSITION_ANIMATION_ID,
5+ runMobileComposerTransition,
6+ waitForDraftHeroTransition,
7+} from "./draftHeroTransition";
8+
9+afterEach(() => {
10+ vi.unstubAllGlobals();
11+});
12+
13+describe("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+
52+describe("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+82−0
@@ -0,0 +1,82 @@
1+export const DRAFT_HERO_TRANSITION_ANIMATION_ID = "t3-draft-hero-transition";
2+export const DRAFT_HERO_TRANSITION_DURATION_MS = 180;
3+export const DRAFT_HERO_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
4+export const MOBILE_COMPOSER_VIEW_TRANSITION_NAME = "t3-mobile-composer";
5+export const MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME = "t3-mobile-draft-headline";
6+
7+type ComposerViewTransition = {
8+ readonly finished: Promise<void>;
9+};
10+
11+let activeMobileComposerTransition: Promise<void> | null = null;
12+
13+type ComposerViewTransitionDocument = Document & {
14+ startViewTransition?: (update: () => void | Promise<void>) => ComposerViewTransition;
15+};
16+
17+export 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+
40+export 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−0
@@ -47,6 +47,7 @@ export function useNewThreadHandler() { · 46 unmodified lines
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() { · 67 unmodified lines
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() { · 54 unmodified lines
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−0
@@ -4,6 +4,77 @@ · 3 unmodified lines
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. */
9+html[data-mobile-composer-route-transition="true"]::view-transition-old(root),
10+html[data-mobile-composer-route-transition="true"]::view-transition-new(root) {
11+ animation: none;
12+}
13+
14+html[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+
19+html[data-mobile-composer-route-transition="true"]::view-transition-image-pair(t3-mobile-composer) {
20+ isolation: isolate;
21+}
22+
23+html[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+
28+html[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+
58+html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) {
59+ animation-duration: 130ms;
60+}
61+
62+html[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 { · 7 unmodified lines
88 useComposerDraftStore,
99 } from "../composerDraftStore";
1010 import { SidebarInset } from "../components/ui/sidebar";
11+import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition";
1112 import { buildThreadRouteParams } from "../threadRoutes";
1213 import { useThread, useThreadRefs } from "../state/entities";
1314
@@ -40,11 +41,22 @@ function DraftChatThreadRouteView() { · 26 unmodified lines
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() { · 3 unmodified lines
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() { · 5 unmodified lines
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 @@
1+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
12 import { createFileRoute, Link } from "@tanstack/react-router";
2−import { LinkIcon, PlusIcon } from "lucide-react";
3+import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
4+import { useEffect, useMemo, useRef, useState } from "react";
35
4−import { NoActiveThreadState } from "../components/NoActiveThreadState";
6+import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
7+import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
58 import { Button } from "../components/ui/button";
69 import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
710 import { SidebarInset } from "../components/ui/sidebar";
11+import { useNewThreadHandler } from "../hooks/useHandleNewThread";
12+import {
13+ useAllEnvironmentShellsBootstrapped,
14+ useProjects,
15+ useThreadShells,
16+} from "../state/entities";
817 import { useEnvironments } from "../state/environments";
918 import { APP_DISPLAY_NAME } from "~/branding";
1019 import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -19,7 +28,109 @@ function ChatIndexRouteView() { · 8 unmodified lines
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+ */
39+function 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+
86+function 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+
107+function 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
25136 export const Route = createFileRoute("/_chat/")({
apps/web/src/state/entities.ts+5−0
@@ -20,6 +20,7 @@ import { useMemo } from "react"; · 19 unmodified lines
2020 import { appAtomRegistry } from "../rpc/atomRegistry";
2121 import { environmentProjects } from "./projects";
2222 import { environmentServerConfigsAtom } from "./server";
23+import { allEnvironmentShellsBootstrappedAtom } from "./shell";
2324 import { environmentThreadDetails, environmentThreadShells } from "./threads";
2425
2526 const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
@@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> { · 87 unmodified lines
113114 return useAtomValue(environmentThreadShells.threadShellsAtom);
114115 }
115116
117+export function useAllEnvironmentShellsBootstrapped(): boolean {
118+ return useAtomValue(allEnvironmentShellsBootstrappedAtom);
119+}
120+
116121 export function useThreadShellsForProjectRefs(
117122 refs: ReadonlyArray<ScopedProjectRef>,
118123 ): ReadonlyArray<EnvironmentThreadShell> {
apps/web/src/state/shell.ts+31−0
@@ -1,9 +1,15 @@
1+import {
2+ AVAILABLE_CONNECTION_STATE,
3+ connectionProjectionPhase,
4+} from "@t3tools/client-runtime/connection";
15 import {
26 createEnvironmentShellAtoms,
37 createEnvironmentShellSummaryAtom,
48 createEnvironmentSnapshotAtom,
59 createShellEnvironmentAtoms,
610 } from "@t3tools/client-runtime/state/shell";
11+import * as Option from "effect/Option";
12+import { AsyncResult, Atom } from "effect/unstable/reactivity";
713
814 import { environmentCatalog } from "../connection/catalog";
915 import { connectionAtomRuntime } from "../connection/runtime";
@@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ · 5 unmodified lines
1521 catalogValueAtom: environmentCatalog.catalogValueAtom,
1622 shellStateValueAtom: environmentShell.stateValueAtom,
1723 });
24+
25+export 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"));