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 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";2import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
3import { useLocation, useNavigate } from "@tanstack/react-router";3import { useLocation, useNavigate } from "@tanstack/react-router";
44
5import { isElectron } from "../env";5import { isElectron } from "../env";
...@@ -56,8 +56,6 @@ function SidebarControl() {...@@ -56,8 +56,6 @@ function SidebarControl() {
56export function AppSidebarLayout({ children }: { children: ReactNode }) {56export function AppSidebarLayout({ children }: { children: ReactNode }) {
57 const navigate = useNavigate();57 const navigate = useNavigate();
58 const pathname = useLocation({ select: (location) => location.pathname });58 const pathname = useLocation({ select: (location) => location.pathname });
59 const pathnameRef = useRef(pathname);
60 pathnameRef.current = pathname;
61 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);59 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
62 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {60 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {
63 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;61 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;
...@@ -95,7 +93,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -95,7 +93,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
9593
96 const unsubscribe = onMenuAction((action) => {94 const unsubscribe = onMenuAction((action) => {
97 if (action === "open-settings") {95 if (action === "open-settings") {
98 const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current);96 const isSettingsRoute = /^\/settings(\/|$)/.test(pathname);
99 if (!isSettingsRoute) {97 if (!isSettingsRoute) {
100 void navigate({ to: "/settings" });98 void navigate({ to: "/settings" });
101 }99 }
...@@ -105,7 +103,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -105,7 +103,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
105 return () => {103 return () => {
106 unsubscribe?.();104 unsubscribe?.();
107 };105 };
108 }, [navigate]);106 }, [navigate, pathname]);
109107
110 return (108 return (
111 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>109 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
apps/web/src/components/ChatView.tsx+357-135
...@@ -53,6 +53,7 @@ import {...@@ -53,6 +53,7 @@ import {
53 useRef,53 useRef,
54 useState,54 useState,
55} from "react";55} from "react";
56import { flushSync } from "react-dom";
56import { useNavigate } from "@tanstack/react-router";57import { useNavigate } from "@tanstack/react-router";
57import { useShallow } from "zustand/react/shallow";58import { useShallow } from "zustand/react/shallow";
58import {59import {
...@@ -201,6 +202,7 @@ import {...@@ -201,6 +202,7 @@ import {
201} from "../state/entities";202} from "../state/entities";
202import { environmentShell } from "../state/shell";203import { environmentShell } from "../state/shell";
203import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";204import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
205import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
204import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";206import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
205import { PullRequestThreadDialog } from "./PullRequestThreadDialog";207import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
206import { MessagesTimeline } from "./chat/MessagesTimeline";208import { MessagesTimeline } from "./chat/MessagesTimeline";
...@@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";...@@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";
212import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";214import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
213import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";215import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
214import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";216import { 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";
215import {225import {
216 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,226 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
217 buildExpiredTerminalContextToastCopy,227 buildExpiredTerminalContextToastCopy,
...@@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];...@@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
256const EMPTY_PROVIDERS: ServerProvider[] = [];266const EMPTY_PROVIDERS: ServerProvider[] = [];
257const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];267const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
258const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};268const 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}
259const PreviewPanel = lazy(() =>341const PreviewPanel = lazy(() =>
260 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),342 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
261);343);
...@@ -339,6 +421,7 @@ type ChatViewProps =...@@ -339,6 +421,7 @@ type ChatViewProps =
339 threadId: ThreadId;421 threadId: ThreadId;
340 onDiffPanelOpen?: () => void;422 onDiffPanelOpen?: () => void;
341 reserveTitleBarControlInset?: boolean;423 reserveTitleBarControlInset?: boolean;
424 forceExpandedMobileComposer?: boolean;
342 routeKind: "server";425 routeKind: "server";
343 draftId?: never;426 draftId?: never;
344 }427 }
...@@ -347,6 +430,7 @@ type ChatViewProps =...@@ -347,6 +430,7 @@ type ChatViewProps =
347 threadId: ThreadId;430 threadId: ThreadId;
348 onDiffPanelOpen?: () => void;431 onDiffPanelOpen?: () => void;
349 reserveTitleBarControlInset?: boolean;432 reserveTitleBarControlInset?: boolean;
433 forceExpandedMobileComposer?: boolean;
350 routeKind: "draft";434 routeKind: "draft";
351 draftId: DraftId;435 draftId: DraftId;
352 };436 };
...@@ -979,6 +1063,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane...@@ -979,6 +1063,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
979 );1063 );
980});1064});
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
982function ChatViewContent(props: ChatViewProps) {1074function ChatViewContent(props: ChatViewProps) {
983 const {1075 const {
984 environmentId,1076 environmentId,
...@@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) {
986 routeKind,1078 routeKind,
987 onDiffPanelOpen,1079 onDiffPanelOpen,
988 reserveTitleBarControlInset = true,1080 reserveTitleBarControlInset = true,
1081 forceExpandedMobileComposer = false,
989 } = props;1082 } = props;
990 const draftId = routeKind === "draft" ? props.draftId : null;1083 const draftId = routeKind === "draft" ? props.draftId : null;
991 const routeThreadRef = useMemo(1084 const routeThreadRef = useMemo(
...@@ -1035,10 +1128,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1035,10 +1128,10 @@ function ChatViewContent(props: ChatViewProps) {
1035 );1128 );
1036 const composerDraftTarget: ScopedThreadRef | DraftId =1129 const composerDraftTarget: ScopedThreadRef | DraftId =
1037 routeKind === "server" ? routeThreadRef : props.draftId;1130 routeKind === "server" ? routeThreadRef : props.draftId;
1038 const serverThread = useThread(routeKind === "server" ? routeThreadRef : null);1131 const serverThread = useThread(routeThreadRef);
1039 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1132 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1040 const activeThreadLastVisitedAt = useUiStateStore((store) =>1133 const activeThreadLastVisitedAt = useUiStateStore(
1041 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,1134 (store) => store.threadLastVisitedAtById[routeThreadKey],
1042 );1135 );
1043 const settings = useEnvironmentSettings(environmentId);1136 const settings = useEnvironmentSettings(environmentId);
1044 const setStickyComposerModelSelection = useComposerDraftStore(1137 const setStickyComposerModelSelection = useComposerDraftStore(
...@@ -1103,10 +1196,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1103,10 +1196,10 @@ function ChatViewContent(props: ChatViewProps) {
1103 const optimisticUserMessagesRef = useRef(optimisticUserMessages);1196 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
1104 optimisticUserMessagesRef.current = optimisticUserMessages;1197 optimisticUserMessagesRef.current = optimisticUserMessages;
1105 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<1198 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
1106 Record<string, string | null>1199 Record<string, LocalThreadErrorEntry>
1107 >({});1200 >({});
1108 const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState<1201 const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState<
1109 Record<string, string | null>1202 Record<string, LocalThreadErrorEntry>
1110 >({});1203 >({});
1111 const [isConnecting, _setIsConnecting] = useState(false);1204 const [isConnecting, _setIsConnecting] = useState(false);
1112 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);1205 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
...@@ -1218,11 +1311,45 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1218,11 +1311,45 @@ function ChatViewContent(props: ChatViewProps) {
1218 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)1311 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
1219 : null;1312 : null;
1220 const fallbackDraftProject = useProject(fallbackDraftProjectRef);1313 const fallbackDraftProject = useProject(fallbackDraftProjectRef);
1221 const localDraftError =1314 const localDraftError = serverThread
1222 routeKind === "server" && serverThread1315 ? null
1223 ? null1316 : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
1224 : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null);1317 const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
1225 const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? 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]);
1226 const localDraftThread = useMemo(1353 const localDraftThread = useMemo(
1227 () =>1354 () =>
1228 draftThread1355 draftThread
...@@ -1237,7 +1364,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1237,7 +1364,10 @@ function ChatViewContent(props: ChatViewProps) {
1237 : undefined,1364 : undefined,
1238 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],1365 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
1239 );1366 );
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;
1241 const activeThread = isServerThread ? serverThread : localDraftThread;1371 const activeThread = isServerThread ? serverThread : localDraftThread;
1242 const threadError = isServerThread1372 const threadError = isServerThread
1243 ? (localServerError ?? serverThread?.session?.lastError ?? null)1373 ? (localServerError ?? serverThread?.session?.lastError ?? null)
...@@ -2063,6 +2193,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2063,6 +2193,16 @@ function ChatViewContent(props: ChatViewProps) {
2063 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),2193 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2064 [activeThread?.proposedPlans, timelineMessages, workLogEntries],2194 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2065 );2195 );
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);
2066 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =2206 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
2067 useTurnDiffSummaries(activeThread);2207 useTurnDiffSummaries(activeThread);
2068 const turnDiffSummaryByAssistantMessageId = useMemo(() => {2208 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
...@@ -2226,6 +2366,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2226,6 +2366,7 @@ function ChatViewContent(props: ChatViewProps) {
2226 (targetThreadId: ThreadId | null, error: string | null) => {2366 (targetThreadId: ThreadId | null, error: string | null) => {
2227 if (!targetThreadId) return;2367 if (!targetThreadId) return;
2228 const nextError = sanitizeThreadErrorMessage(error);2368 const nextError = sanitizeThreadErrorMessage(error);
2369 const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
2229 if (2370 if (
2230 serverThread &&2371 serverThread &&
2231 targetThreadId === routeThreadRef.threadId &&2372 targetThreadId === routeThreadRef.threadId &&
...@@ -2233,24 +2374,24 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2233,24 +2374,24 @@ function ChatViewContent(props: ChatViewProps) {
2233 serverThread.id === targetThreadId2374 serverThread.id === targetThreadId
2234 ) {2375 ) {
2235 setLocalServerErrorsByThreadKey((existing) => {2376 setLocalServerErrorsByThreadKey((existing) => {
2236 if ((existing[routeThreadKey] ?? null) === nextError) {2377 if ((existing[routeThreadKey]?.message ?? null) === nextError) {
2237 return existing;2378 return existing;
2238 }2379 }
2239 return {2380 return {
2240 ...existing,2381 ...existing,
2241 [routeThreadKey]: nextError,2382 [routeThreadKey]: nextEntry,
2242 };2383 };
2243 });2384 });
2244 return;2385 return;
2245 }2386 }
2246 const localDraftErrorKey = draftId ?? targetThreadId;2387 const localDraftErrorKey = draftId ?? targetThreadId;
2247 setLocalDraftErrorsByDraftId((existing) => {2388 setLocalDraftErrorsByDraftId((existing) => {
2248 if ((existing[localDraftErrorKey] ?? null) === nextError) {2389 if ((existing[localDraftErrorKey]?.message ?? null) === nextError) {
2249 return existing;2390 return existing;
2250 }2391 }
2251 return {2392 return {
2252 ...existing,2393 ...existing,
2253 [localDraftErrorKey]: nextError,2394 [localDraftErrorKey]: nextEntry,
2254 };2395 };
2255 });2396 });
2256 },2397 },
...@@ -3963,7 +4104,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3963,7 +4104,16 @@ function ChatViewContent(props: ChatViewProps) {
3963 }4104 }
3964 return;4105 return;
3965 }4106 }
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 }
3967 const threadIdForSend = activeThread.id;4117 const threadIdForSend = activeThread.id;
3968 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;4118 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
3969 const baseBranchForWorktree =4119 const baseBranchForWorktree =
...@@ -3981,6 +4131,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3981,6 +4131,21 @@ function ChatViewContent(props: ChatViewProps) {
3981 }4131 }
39824132
3983 sendInFlightRef.current = true;4133 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 }
3984 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });4149 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
39854150
3986 const composerImagesSnapshot = [...composerImages];4151 const composerImagesSnapshot = [...composerImages];
...@@ -4234,6 +4399,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4234,6 +4399,9 @@ function ChatViewContent(props: ChatViewProps) {
4234 }4399 }
4235 sendInFlightRef.current = false;4400 sendInFlightRef.current = false;
4236 if (!turnStartSucceeded) {4401 if (!turnStartSucceeded) {
4402 setDockedDraftHeroThreadKey((currentThreadKey) =>
4403 currentThreadKey === activeThreadKey ? null : currentThreadKey,
4404 );
4237 resetLocalDispatch();4405 resetLocalDispatch();
4238 }4406 }
4239 };4407 };
...@@ -5101,6 +5269,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5101,6 +5269,7 @@ function ChatViewContent(props: ChatViewProps) {
5101 contentInsetEndAdjustment={composerOverlayHeight}5269 contentInsetEndAdjustment={composerOverlayHeight}
5102 onIsAtEndChange={onIsAtEndChange}5270 onIsAtEndChange={onIsAtEndChange}
5103 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}5271 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
5272 hideEmptyPlaceholder={isDraftHeroState}
5104 />5273 />
51055274
5106 {/* scroll to end pill — shown when user has scrolled away from the live edge */}5275 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
...@@ -5123,134 +5292,187 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5123,134 +5292,187 @@ function ChatViewContent(props: ChatViewProps) {
5123 )}5292 )}
5124 </div>5293 </div>
51255294
5126 {/* Input bar */}5295 {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
5127 <div5296 <div
5128 ref={setComposerOverlayElement}5297 ref={setComposerOverlayElement}
5129 data-chat-composer-overlay="true"5298 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 }
5131 >5304 >
5132 <div5305 {!isDraftHeroState ? (
5133 aria-hidden="true"5306 <div
5134 className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2"5307 key="docked-composer-blur"
5135 >5308 aria-hidden="true"
5136 <div className="relative mx-auto h-full w-full max-w-3xl overflow-clip rounded-t-[20px]">5309 className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2"
5137 <div className="chat-composer-shared-blur absolute -inset-8" />5310 >
5138 </div>5311 <div className="relative mx-auto h-full w-full max-w-3xl overflow-clip rounded-t-[20px]">
5139 </div>5312 <div className="chat-composer-shared-blur absolute -inset-8" />
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 />
5215 </div>5313 </div>
5216 </div>5314 </div>
5217 </div>5315 ) : null}
5218 <div5316 <div
5219 className={cn(5317 ref={attachDraftHeroTransitionGroupRef}
5220 "chat-composer-horizontal-inset chat-composer-lower-chrome relative z-10",5318 className="chat-composer-horizontal-inset w-full"
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 )}
5225 >5319 >
5226 {isGitRepo && (5320 <div className="pointer-events-auto relative z-10 isolate">
5227 <div className="pointer-events-auto">5321 {isDraftHeroState ? (
5228 <BranchToolbar5322 <div className="absolute inset-x-0 bottom-full z-0">
5229 environmentId={activeThread.environmentId}5323 <div
5230 threadId={activeThread.id}5324 className="pb-8"
5231 {...(routeKind === "draft" && draftId ? { draftId } : {})}5325 style={
5232 onEnvModeChange={onEnvModeChange}5326 forceExpandedMobileComposer
5233 startFromOrigin={startFromOrigin}5327 ? {
5234 onStartFromOriginChange={onStartFromOriginChange}5328 viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
5235 {...(canOverrideServerThreadEnvMode5329 }
5236 ? { effectiveEnvModeOverride: envMode }5330 : undefined
5237 : {})}5331 }
5238 {...(canOverrideServerThreadEnvMode5332 >
5239 ? {5333 <DraftHeroHeadline
5240 activeThreadBranchOverride: activeThreadBranch,5334 activeProjectRef={activeProjectRef}
5241 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,5335 activeProjectTitle={activeProject?.title ?? null}
5242 }5336 />
5243 : {})}5337 </div>
5244 envLocked={envLocked}5338 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5245 onComposerFocusRequest={scheduleComposerFocus}5339 </div>
5246 {...(canCheckoutPullRequestIntoThread5340 ) : (
5247 ? { onCheckoutPullRequestRequest: openPullRequestDialog }5341 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
5248 : {})}5342 )}
5249 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}5343 <div
5250 availableEnvironments={logicalProjectEnvironments}5344 className="relative"
5251 />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>
5252 </div>5474 </div>
5253 )}5475 </div>
5254 </div>5476 </div>
5255 </div>5477 </div>
52565478
apps/web/src/components/Sidebar.logic.test.ts+71
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 resolveThreadStatusPill,19 resolveThreadStatusPill,
20 shouldClearThreadSelectionOnMouseDown,20 shouldClearThreadSelectionOnMouseDown,
21 sortProjectsForSidebar,21 sortProjectsForSidebar,
22 sortScopedProjectsForSidebar,
22 THREAD_JUMP_HINT_SHOW_DELAY_MS,23 THREAD_JUMP_HINT_SHOW_DELAY_MS,
23} from "./Sidebar.logic";24} from "./Sidebar.logic";
24import {25import {
...@@ -1066,3 +1067,73 @@ describe("sortProjectsForSidebar", () => {...@@ -1066,3 +1067,73 @@ describe("sortProjectsForSidebar", () => {
1066 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));1067 expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
1067 });1068 });
1068});1069});
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 = {...@@ -24,6 +24,16 @@ type SidebarProject = {
24 updatedAt?: string | undefined;24 updatedAt?: string | undefined;
25};25};
2626
27type ScopedSidebarProject = SidebarProject & {
28 environmentId: string;
29};
30
31type ScopedSidebarThread = ThreadSortInput & {
32 environmentId: string;
33 projectId: string;
34 archivedAt: string | null;
35};
36
27export type ThreadTraversalDirection = "previous" | "next";37export type ThreadTraversalDirection = "previous" | "next";
2838
29export interface ThreadStatusPill {39export interface ThreadStatusPill {
...@@ -534,6 +544,25 @@ export function getProjectSortTimestamp(...@@ -534,6 +544,25 @@ export function getProjectSortTimestamp(
534 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;544 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
535}545}
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
537export function sortProjectsForSidebar<566export function sortProjectsForSidebar<
538 TProject extends SidebarProject,567 TProject extends SidebarProject,
539 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,568 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
...@@ -542,10 +571,6 @@ export function sortProjectsForSidebar<...@@ -542,10 +571,6 @@ export function sortProjectsForSidebar<
542 threads: readonly TThread[],571 threads: readonly TThread[],
543 sortOrder: SidebarProjectSortOrder,572 sortOrder: SidebarProjectSortOrder,
544): TProject[] {573): TProject[] {
545 if (sortOrder === "manual") {
546 return [...projects];
547 }
548
549 const threadsByProjectId = new Map<string, TThread[]>();574 const threadsByProjectId = new Map<string, TThread[]>();
550 for (const thread of threads) {575 for (const thread of threads) {
551 const existing = threadsByProjectId.get(thread.projectId) ?? [];576 const existing = threadsByProjectId.get(thread.projectId) ?? [];
...@@ -553,20 +578,47 @@ export function sortProjectsForSidebar<...@@ -553,20 +578,47 @@ export function sortProjectsForSidebar<
553 threadsByProjectId.set(thread.projectId, existing);578 threadsByProjectId.set(thread.projectId, existing);
554 }579 }
555580
556 return [...projects].toSorted((left, right) => {581 return sortProjectsByActivity(
557 const rightTimestamp = getProjectSortTimestamp(582 projects,
558 right,583 sortOrder,
559 threadsByProjectId.get(right.id) ?? [],584 (project) => threadsByProjectId.get(project.id) ?? [],
560 sortOrder,585 (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id),
561 );586 );
562 const leftTimestamp = getProjectSortTimestamp(587}
563 left,588
564 threadsByProjectId.get(left.id) ?? [],589/**
565 sortOrder,590 * Sorts the cross-environment project collection used by landing surfaces.
566 );591 * Project ids are only unique within an environment, and archived threads
567 const byTimestamp =592 * must not make a project appear recently active.
568 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;593 */
569 if (byTimestamp !== 0) return byTimestamp;594export function sortScopedProjectsForSidebar<
570 return left.title.localeCompare(right.title) || left.id.localeCompare(right.id);595 TProject extends ScopedSidebarProject,
571 });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 );
572}624}
apps/web/src/components/chat/ChatComposer.tsx+34-14
...@@ -442,6 +442,8 @@ export interface ChatComposerProps {...@@ -442,6 +442,8 @@ export interface ChatComposerProps {
442 activeThread: Thread | undefined;442 activeThread: Thread | undefined;
443 isServerThread: boolean;443 isServerThread: boolean;
444 isLocalDraftThread: boolean;444 isLocalDraftThread: boolean;
445 forceExpandedOnMobile: boolean;
446 projectSelectionRequired: boolean;
445447
446 // Session phase448 // Session phase
447 phase: SessionPhase;449 phase: SessionPhase;
...@@ -553,6 +555,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -553,6 +555,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
553 activeThread,555 activeThread,
554 isServerThread: _isServerThread,556 isServerThread: _isServerThread,
555 isLocalDraftThread: _isLocalDraftThread,557 isLocalDraftThread: _isLocalDraftThread,
558 forceExpandedOnMobile,
559 projectSelectionRequired,
556 phase,560 phase,
557 isConnecting,561 isConnecting,
558 isSendBusy,562 isSendBusy,
...@@ -883,7 +887,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -883,7 +887,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
883 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);887 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
884 const [isComposerFocused, setIsComposerFocused] = useState(false);888 const [isComposerFocused, setIsComposerFocused] = useState(false);
885 const isMobileViewport = useMediaQuery("max-sm");889 const isMobileViewport = useMediaQuery("max-sm");
886 const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;890 const isComposerCollapsedMobile =
891 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
887892
888 // ------------------------------------------------------------------893 // ------------------------------------------------------------------
889 // Refs894 // Refs
...@@ -1136,7 +1141,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1136,7 +1141,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1136 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],1141 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1137 );1142 );
1138 const collapsedComposerPrimaryActionDisabled =1143 const collapsedComposerPrimaryActionDisabled =
1139 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;1144 phase === "running" ||
1145 isSendBusy ||
1146 isConnecting ||
1147 projectSelectionRequired ||
1148 !composerSendState.hasSendableContent;
1140 const collapsedComposerPrimaryActionLabel = "Send message";1149 const collapsedComposerPrimaryActionLabel = "Send message";
1141 const showMobilePendingAnswerActions =1150 const showMobilePendingAnswerActions =
1142 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;1151 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
...@@ -1928,6 +1937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1928,6 +1937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1928 isConnecting ||1937 isConnecting ||
1929 isComposerApprovalState ||1938 isComposerApprovalState ||
1930 pendingUserInputs.length > 0 ||1939 pendingUserInputs.length > 0 ||
1940 projectSelectionRequired ||
1931 (environmentUnavailable !== null && activePendingProgress === null)1941 (environmentUnavailable !== null && activePendingProgress === null)
1932 ) {1942 ) {
1933 return false;1943 return false;
...@@ -2028,6 +2038,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2028,6 +2038,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2028 isConnecting,2038 isConnecting,
2029 isComposerApprovalState,2039 isComposerApprovalState,
2030 pendingUserInputs.length,2040 pendingUserInputs.length,
2041 projectSelectionRequired,
2031 environmentUnavailable,2042 environmentUnavailable,
2032 activePendingProgress,2043 activePendingProgress,
2033 applyPromptReplacement,2044 applyPromptReplacement,
...@@ -2065,9 +2076,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2065,9 +2076,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2065 ref={composerSurfaceRef}2076 ref={composerSurfaceRef}
2066 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}2077 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
2067 className={cn(2078 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",
2069 isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",2080 isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",
2070 environmentUnavailable ? "opacity-75" : null,2081 environmentUnavailable || projectSelectionRequired ? "opacity-75" : null,
2071 composerProviderState.composerSurfaceClassName,2082 composerProviderState.composerSurfaceClassName,
2072 )}2083 )}
2073 onFocusCapture={(event) => {2084 onFocusCapture={(event) => {
...@@ -2179,7 +2190,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2179,7 +2190,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2179 promptHasText={false}2190 promptHasText={false}
2180 isSendBusy={isSendBusy}2191 isSendBusy={isSendBusy}
2181 isConnecting={isConnecting}2192 isConnecting={isConnecting}
2182 isEnvironmentUnavailable={environmentUnavailable !== null}2193 isEnvironmentUnavailable={
2194 environmentUnavailable !== null || projectSelectionRequired
2195 }
2183 isPreparingWorktree={false}2196 isPreparingWorktree={false}
2184 hasSendableContent={false}2197 hasSendableContent={false}
2185 preserveComposerFocusOnPointerDown2198 preserveComposerFocusOnPointerDown
...@@ -2413,17 +2426,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2413,17 +2426,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2413 ? "Type your own answer, or leave this blank to use the selected option"2426 ? "Type your own answer, or leave this blank to use the selected option"
2414 : showPlanFollowUpPrompt && activeProposedPlan2427 : showPlanFollowUpPrompt && activeProposedPlan
2415 ? "Add feedback to refine the plan, or leave this blank to implement it"2428 ? "Add feedback to refine the plan, or leave this blank to implement it"
2416 : environmentUnavailable2429 : projectSelectionRequired
2417 ? `${environmentUnavailable.label}: ${connectionStatusText(2430 ? "Choose a project above to start a thread"
2418 environmentUnavailable.connection,2431 : environmentUnavailable
2419 )}`2432 ? `${environmentUnavailable.label}: ${connectionStatusText(
2420 : phase === "disconnected"2433 environmentUnavailable.connection,
2421 ? "Ask for follow-up changes or attach images"2434 )}`
2422 : "Ask anything, @tag files/folders, $use skills, or / for commands"2435 : phase === "disconnected"
2436 ? "Ask for follow-up changes or attach images"
2437 : "Ask anything, @tag files/folders, $use skills, or / for commands"
2423 }2438 }
2424 disabled={2439 disabled={
2425 isConnecting ||2440 isConnecting ||
2426 isComposerApprovalState ||2441 isComposerApprovalState ||
2442 projectSelectionRequired ||
2427 (environmentUnavailable !== null && activePendingProgress === null)2443 (environmentUnavailable !== null && activePendingProgress === null)
2428 }2444 }
2429 />2445 />
...@@ -2440,7 +2456,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2440,7 +2456,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2440 promptHasText={false}2456 promptHasText={false}
2441 isSendBusy={isSendBusy}2457 isSendBusy={isSendBusy}
2442 isConnecting={isConnecting}2458 isConnecting={isConnecting}
2443 isEnvironmentUnavailable={environmentUnavailable !== null}2459 isEnvironmentUnavailable={
2460 environmentUnavailable !== null || projectSelectionRequired
2461 }
2444 isPreparingWorktree={false}2462 isPreparingWorktree={false}
2445 hasSendableContent={false}2463 hasSendableContent={false}
2446 preserveComposerFocusOnPointerDown2464 preserveComposerFocusOnPointerDown
...@@ -2551,7 +2569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2551,7 +2569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2551 promptHasText={prompt.trim().length > 0}2569 promptHasText={prompt.trim().length > 0}
2552 isSendBusy={isSendBusy}2570 isSendBusy={isSendBusy}
2553 isConnecting={isConnecting}2571 isConnecting={isConnecting}
2554 isEnvironmentUnavailable={environmentUnavailable !== null}2572 isEnvironmentUnavailable={
2573 environmentUnavailable !== null || projectSelectionRequired
2574 }
2555 isPreparingWorktree={isPreparingWorktree}2575 isPreparingWorktree={isPreparingWorktree}
2556 hasSendableContent={composerSendState.hasSendableContent}2576 hasSendableContent={composerSendState.hasSendableContent}
2557 preserveComposerFocusOnPointerDown={isMobileViewport}2577 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 {...@@ -179,6 +179,7 @@ interface MessagesTimelineProps {
179 contentInsetEndAdjustment: number;179 contentInsetEndAdjustment: number;
180 onIsAtEndChange: (isAtEnd: boolean) => void;180 onIsAtEndChange: (isAtEnd: boolean) => void;
181 onManualNavigation: () => void;181 onManualNavigation: () => void;
182 hideEmptyPlaceholder?: boolean;
182}183}
183184
184// ---------------------------------------------------------------------------185// ---------------------------------------------------------------------------
...@@ -212,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -212,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
212 contentInsetEndAdjustment,213 contentInsetEndAdjustment,
213 onIsAtEndChange,214 onIsAtEndChange,
214 onManualNavigation,215 onManualNavigation,
216 hideEmptyPlaceholder = false,
215}: MessagesTimelineProps) {217}: MessagesTimelineProps) {
216 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());218 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
217 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());219 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
...@@ -458,6 +460,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -458,6 +460,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
458 );460 );
459461
460 if (rows.length === 0 && !isWorking) {462 if (rows.length === 0 && !isWorking) {
463 if (hideEmptyPlaceholder) {
464 return null;
465 }
461 return (466 return (
462 <div className="flex h-full items-center justify-center">467 <div className="flex h-full items-center justify-center">
463 <p className="text-sm text-muted-foreground/30">468 <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() {...@@ -47,6 +47,7 @@ export function useNewThreadHandler() {
47 worktreePath?: string | null;47 worktreePath?: string | null;
48 envMode?: DraftThreadEnvMode;48 envMode?: DraftThreadEnvMode;
49 startFromOrigin?: boolean;49 startFromOrigin?: boolean;
50 replace?: boolean;
50 },51 },
51 ): Promise<void> => {52 ): Promise<void> => {
52 const {53 const {
...@@ -120,6 +121,7 @@ export function useNewThreadHandler() {...@@ -120,6 +121,7 @@ export function useNewThreadHandler() {
120 await router.navigate({121 await router.navigate({
121 to: "/draft/$draftId",122 to: "/draft/$draftId",
122 params: { draftId: reusableStoredDraftThread.draftId },123 params: { draftId: reusableStoredDraftThread.draftId },
124 replace: options?.replace ?? false,
123 });125 });
124 })();126 })();
125 }127 }
...@@ -180,6 +182,7 @@ export function useNewThreadHandler() {...@@ -180,6 +182,7 @@ export function useNewThreadHandler() {
180 await router.navigate({182 await router.navigate({
181 to: "/draft/$draftId",183 to: "/draft/$draftId",
182 params: { draftId },184 params: { draftId },
185 replace: options?.replace ?? false,
183 });186 });
184 })();187 })();
185 },188 },
apps/web/src/index.css+71
...@@ -4,6 +4,77 @@...@@ -4,6 +4,77 @@
4/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */4/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
5@custom-variant wco (&:is(.wco, .wco *));5@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
7:root {78:root {
8 --app-scrollbar-width: 6px;79 --app-scrollbar-width: 6px;
9 --desktop-window-right-resize-inset: 0px;80 --desktop-window-right-resize-inset: 0px;
apps/web/src/routes/_chat.draft.$draftId.tsx+17-16
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 useComposerDraftStore,8 useComposerDraftStore,
9} from "../composerDraftStore";9} from "../composerDraftStore";
10import { SidebarInset } from "../components/ui/sidebar";10import { SidebarInset } from "../components/ui/sidebar";
11import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition";
11import { buildThreadRouteParams } from "../threadRoutes";12import { buildThreadRouteParams } from "../threadRoutes";
12import { useThread, useThreadRefs } from "../state/entities";13import { useThread, useThreadRefs } from "../state/entities";
1314
...@@ -40,11 +41,22 @@ function DraftChatThreadRouteView() {...@@ -40,11 +41,22 @@ function DraftChatThreadRouteView() {
40 if (!canonicalThreadRef) {41 if (!canonicalThreadRef) {
41 return;42 return;
42 }43 }
43 void navigate({44
44 to: "/$environmentId/$threadId",45 let cancelled = false;
45 params: buildThreadRouteParams(canonicalThreadRef),46 void waitForDraftHeroTransition().then(() => {
46 replace: true,47 if (cancelled) {
48 return;
49 }
50 void navigate({
51 to: "/$environmentId/$threadId",
52 params: buildThreadRouteParams(canonicalThreadRef),
53 replace: true,
54 });
47 });55 });
56
57 return () => {
58 cancelled = true;
59 };
48 }, [canonicalThreadRef, navigate]);60 }, [canonicalThreadRef, navigate]);
4961
50 useEffect(() => {62 useEffect(() => {
...@@ -54,18 +66,6 @@ function DraftChatThreadRouteView() {...@@ -54,18 +66,6 @@ function DraftChatThreadRouteView() {
54 void navigate({ to: "/", replace: true });66 void navigate({ to: "/", replace: true });
55 }, [canonicalThreadRef, draftSession, navigate]);67 }, [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
69 if (!draftSession) {69 if (!draftSession) {
70 return null;70 return null;
71 }71 }
...@@ -77,6 +77,7 @@ function DraftChatThreadRouteView() {...@@ -77,6 +77,7 @@ function DraftChatThreadRouteView() {
77 environmentId={draftSession.environmentId}77 environmentId={draftSession.environmentId}
78 threadId={draftSession.threadId}78 threadId={draftSession.threadId}
79 routeKind="draft"79 routeKind="draft"
80 forceExpandedMobileComposer
80 />81 />
81 </SidebarInset>82 </SidebarInset>
82 );83 );
apps/web/src/routes/_chat.index.tsx+114-3
...@@ -1,10 +1,19 @@...@@ -1,10 +1,19 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
1import { createFileRoute, Link } from "@tanstack/react-router";2import { 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";
5import { Button } from "../components/ui/button";8import { Button } from "../components/ui/button";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
7import { SidebarInset } from "../components/ui/sidebar";10import { SidebarInset } from "../components/ui/sidebar";
11import { useNewThreadHandler } from "../hooks/useHandleNewThread";
12import {
13 useAllEnvironmentShellsBootstrapped,
14 useProjects,
15 useThreadShells,
16} from "../state/entities";
8import { useEnvironments } from "../state/environments";17import { useEnvironments } from "../state/environments";
9import { APP_DISPLAY_NAME } from "~/branding";18import { APP_DISPLAY_NAME } from "~/branding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";19import { hasCloudPublicConfig } from "~/cloud/publicConfig";
...@@ -19,7 +28,109 @@ function ChatIndexRouteView() {...@@ -19,7 +28,109 @@ function ChatIndexRouteView() {
19 return <HostedStaticOnboardingState />;28 return <HostedStaticOnboardingState />;
20 }29 }
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 );
23}134}
24135
25export const Route = createFileRoute("/_chat/")({136export const Route = createFileRoute("/_chat/")({
apps/web/src/state/entities.ts+5
...@@ -20,6 +20,7 @@ import { useMemo } from "react";...@@ -20,6 +20,7 @@ import { useMemo } from "react";
20import { appAtomRegistry } from "../rpc/atomRegistry";20import { appAtomRegistry } from "../rpc/atomRegistry";
21import { environmentProjects } from "./projects";21import { environmentProjects } from "./projects";
22import { environmentServerConfigsAtom } from "./server";22import { environmentServerConfigsAtom } from "./server";
23import { allEnvironmentShellsBootstrappedAtom } from "./shell";
23import { environmentThreadDetails, environmentThreadShells } from "./threads";24import { environmentThreadDetails, environmentThreadShells } from "./threads";
2425
25const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);26const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
...@@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {...@@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
113 return useAtomValue(environmentThreadShells.threadShellsAtom);114 return useAtomValue(environmentThreadShells.threadShellsAtom);
114}115}
115116
117export function useAllEnvironmentShellsBootstrapped(): boolean {
118 return useAtomValue(allEnvironmentShellsBootstrappedAtom);
119}
120
116export function useThreadShellsForProjectRefs(121export function useThreadShellsForProjectRefs(
117 refs: ReadonlyArray<ScopedProjectRef>,122 refs: ReadonlyArray<ScopedProjectRef>,
118): ReadonlyArray<EnvironmentThreadShell> {123): ReadonlyArray<EnvironmentThreadShell> {
apps/web/src/state/shell.ts+31
...@@ -1,9 +1,15 @@...@@ -1,9 +1,15 @@
1import {
2 AVAILABLE_CONNECTION_STATE,
3 connectionProjectionPhase,
4} from "@t3tools/client-runtime/connection";
1import {5import {
2 createEnvironmentShellAtoms,6 createEnvironmentShellAtoms,
3 createEnvironmentShellSummaryAtom,7 createEnvironmentShellSummaryAtom,
4 createEnvironmentSnapshotAtom,8 createEnvironmentSnapshotAtom,
5 createShellEnvironmentAtoms,9 createShellEnvironmentAtoms,
6} from "@t3tools/client-runtime/state/shell";10} from "@t3tools/client-runtime/state/shell";
11import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";
713
8import { environmentCatalog } from "../connection/catalog";14import { environmentCatalog } from "../connection/catalog";
9import { connectionAtomRuntime } from "../connection/runtime";15import { connectionAtomRuntime } from "../connection/runtime";
...@@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({...@@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
15 catalogValueAtom: environmentCatalog.catalogValueAtom,21 catalogValueAtom: environmentCatalog.catalogValueAtom,
16 shellStateValueAtom: environmentShell.stateValueAtom,22 shellStateValueAtom: environmentShell.stateValueAtom,
17});23});
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"));