Unified · Split
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | |
| 5 | 5 | import { isElectron } from "../env"; |
| @@ -56,8 +56,6 @@ function SidebarControl() { · 50 unmodified lines | ||
| 56 | 56 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 57 | 57 | const navigate = useNavigate(); |
| 58 | 58 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 59 | − const pathnameRef = useRef(pathname); | |
| 60 | − pathnameRef.current = pathname; | |
| 61 | 59 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 62 | 60 | const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { |
| 63 | 61 | const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; |
| @@ -95,7 +93,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { · 31 unmodified lines | ||
| 95 | 93 | |
| 96 | 94 | const unsubscribe = onMenuAction((action) => { |
| 97 | 95 | if (action === "open-settings") { |
| 98 | − const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); | |
| 96 | + const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); | |
| 99 | 97 | if (!isSettingsRoute) { |
| 100 | 98 | void navigate({ to: "/settings" }); |
| 101 | 99 | } |
| @@ -105,7 +103,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { · 3 unmodified lines | ||
| 105 | 103 | return () => { |
| 106 | 104 | unsubscribe?.(); |
| 107 | 105 | }; |
| 108 | − }, [navigate]); | |
| 106 | + }, [navigate, pathname]); | |
| 109 | 107 | |
| 110 | 108 | return ( |
| 111 | 109 | <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}> |
| @@ -53,6 +53,7 @@ import { · 52 unmodified lines | ||
| 53 | 53 | useRef, |
| 54 | 54 | useState, |
| 55 | 55 | } from "react"; |
| 56 | +import { flushSync } from "react-dom"; | |
| 56 | 57 | import { useNavigate } from "@tanstack/react-router"; |
| 57 | 58 | import { useShallow } from "zustand/react/shallow"; |
| 58 | 59 | import { |
| @@ -201,6 +202,7 @@ import { · 142 unmodified lines | ||
| 201 | 202 | } from "../state/entities"; |
| 202 | 203 | import { environmentShell } from "../state/shell"; |
| 203 | 204 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 205 | +import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; | |
| 204 | 206 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 205 | 207 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 206 | 208 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| @@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; · 5 unmodified lines | ||
| 212 | 214 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 213 | 215 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 214 | 216 | 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"; | |
| 215 | 225 | import { |
| 216 | 226 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 217 | 227 | buildExpiredTerminalContextToastCopy, |
| @@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; · 38 unmodified lines | ||
| 256 | 266 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 257 | 267 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 258 | 268 | 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 | +} | |
| 259 | 341 | const PreviewPanel = lazy(() => |
| 260 | 342 | import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), |
| 261 | 343 | ); |
| @@ -339,6 +421,7 @@ type ChatViewProps = · 77 unmodified lines | ||
| 339 | 421 | threadId: ThreadId; |
| 340 | 422 | onDiffPanelOpen?: () => void; |
| 341 | 423 | reserveTitleBarControlInset?: boolean; |
| 424 | + forceExpandedMobileComposer?: boolean; | |
| 342 | 425 | routeKind: "server"; |
| 343 | 426 | draftId?: never; |
| 344 | 427 | } |
| @@ -347,6 +430,7 @@ type ChatViewProps = · 2 unmodified lines | ||
| 347 | 430 | threadId: ThreadId; |
| 348 | 431 | onDiffPanelOpen?: () => void; |
| 349 | 432 | reserveTitleBarControlInset?: boolean; |
| 433 | + forceExpandedMobileComposer?: boolean; | |
| 350 | 434 | routeKind: "draft"; |
| 351 | 435 | draftId: DraftId; |
| 352 | 436 | }; |
| @@ -979,6 +1063,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane · 626 unmodified lines | ||
| 979 | 1063 | ); |
| 980 | 1064 | }); |
| 981 | 1065 | |
| 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 | + | |
| 982 | 1074 | function ChatViewContent(props: ChatViewProps) { |
| 983 | 1075 | const { |
| 984 | 1076 | environmentId, |
| @@ -986,6 +1078,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 986 | 1078 | routeKind, |
| 987 | 1079 | onDiffPanelOpen, |
| 988 | 1080 | reserveTitleBarControlInset = true, |
| 1081 | + forceExpandedMobileComposer = false, | |
| 989 | 1082 | } = props; |
| 990 | 1083 | const draftId = routeKind === "draft" ? props.draftId : null; |
| 991 | 1084 | const routeThreadRef = useMemo( |
| @@ -1035,10 +1128,10 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines | ||
| 1035 | 1128 | ); |
| 1036 | 1129 | const composerDraftTarget: ScopedThreadRef | DraftId = |
| 1037 | 1130 | routeKind === "server" ? routeThreadRef : props.draftId; |
| 1038 | − const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); | |
| 1131 | + const serverThread = useThread(routeThreadRef); | |
| 1039 | 1132 | 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], | |
| 1042 | 1135 | ); |
| 1043 | 1136 | const settings = useEnvironmentSettings(environmentId); |
| 1044 | 1137 | const setStickyComposerModelSelection = useComposerDraftStore( |
| @@ -1103,10 +1196,10 @@ function ChatViewContent(props: ChatViewProps) { · 58 unmodified lines | ||
| 1103 | 1196 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); |
| 1104 | 1197 | optimisticUserMessagesRef.current = optimisticUserMessages; |
| 1105 | 1198 | const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< |
| 1106 | − Record<string, string | null> | |
| 1199 | + Record<string, LocalThreadErrorEntry> | |
| 1107 | 1200 | >({}); |
| 1108 | 1201 | const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< |
| 1109 | − Record<string, string | null> | |
| 1202 | + Record<string, LocalThreadErrorEntry> | |
| 1110 | 1203 | >({}); |
| 1111 | 1204 | const [isConnecting, _setIsConnecting] = useState(false); |
| 1112 | 1205 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| @@ -1218,11 +1311,45 @@ function ChatViewContent(props: ChatViewProps) { · 105 unmodified lines | ||
| 1218 | 1311 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 1219 | 1312 | : null; |
| 1220 | 1313 | 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]); | |
| 1226 | 1353 | const localDraftThread = useMemo( |
| 1227 | 1354 | () => |
| 1228 | 1355 | draftThread |
| @@ -1237,7 +1364,10 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines | ||
| 1237 | 1364 | : undefined, |
| 1238 | 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 | 1371 | const activeThread = isServerThread ? serverThread : localDraftThread; |
| 1242 | 1372 | const threadError = isServerThread |
| 1243 | 1373 | ? (localServerError ?? serverThread?.session?.lastError ?? null) |
| @@ -2063,6 +2193,16 @@ function ChatViewContent(props: ChatViewProps) { · 819 unmodified lines | ||
| 2063 | 2193 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 2064 | 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 | 2206 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 2067 | 2207 | useTurnDiffSummaries(activeThread); |
| 2068 | 2208 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| @@ -2226,6 +2366,7 @@ function ChatViewContent(props: ChatViewProps) { · 157 unmodified lines | ||
| 2226 | 2366 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 2227 | 2367 | if (!targetThreadId) return; |
| 2228 | 2368 | const nextError = sanitizeThreadErrorMessage(error); |
| 2369 | + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; | |
| 2229 | 2370 | if ( |
| 2230 | 2371 | serverThread && |
| 2231 | 2372 | targetThreadId === routeThreadRef.threadId && |
| @@ -2233,24 +2374,24 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 2233 | 2374 | serverThread.id === targetThreadId |
| 2234 | 2375 | ) { |
| 2235 | 2376 | setLocalServerErrorsByThreadKey((existing) => { |
| 2236 | − if ((existing[routeThreadKey] ?? null) === nextError) { | |
| 2377 | + if ((existing[routeThreadKey]?.message ?? null) === nextError) { | |
| 2237 | 2378 | return existing; |
| 2238 | 2379 | } |
| 2239 | 2380 | return { |
| 2240 | 2381 | ...existing, |
| 2241 | − [routeThreadKey]: nextError, | |
| 2382 | + [routeThreadKey]: nextEntry, | |
| 2242 | 2383 | }; |
| 2243 | 2384 | }); |
| 2244 | 2385 | return; |
| 2245 | 2386 | } |
| 2246 | 2387 | const localDraftErrorKey = draftId ?? targetThreadId; |
| 2247 | 2388 | setLocalDraftErrorsByDraftId((existing) => { |
| 2248 | − if ((existing[localDraftErrorKey] ?? null) === nextError) { | |
| 2389 | + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { | |
| 2249 | 2390 | return existing; |
| 2250 | 2391 | } |
| 2251 | 2392 | return { |
| 2252 | 2393 | ...existing, |
| 2253 | − [localDraftErrorKey]: nextError, | |
| 2394 | + [localDraftErrorKey]: nextEntry, | |
| 2254 | 2395 | }; |
| 2255 | 2396 | }); |
| 2256 | 2397 | }, |
| @@ -3963,7 +4104,16 @@ function ChatViewContent(props: ChatViewProps) { · 1706 unmodified lines | ||
| 3963 | 4104 | } |
| 3964 | 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 | 4117 | const threadIdForSend = activeThread.id; |
| 3968 | 4118 | const isFirstMessage = !isServerThread || activeThread.messages.length === 0; |
| 3969 | 4119 | const baseBranchForWorktree = |
| @@ -3981,6 +4131,21 @@ function ChatViewContent(props: ChatViewProps) { · 11 unmodified lines | ||
| 3981 | 4131 | } |
| 3982 | 4132 | |
| 3983 | 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 | 4149 | beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); |
| 3985 | 4150 | |
| 3986 | 4151 | const composerImagesSnapshot = [...composerImages]; |
| @@ -4234,6 +4399,9 @@ function ChatViewContent(props: ChatViewProps) { · 247 unmodified lines | ||
| 4234 | 4399 | } |
| 4235 | 4400 | sendInFlightRef.current = false; |
| 4236 | 4401 | if (!turnStartSucceeded) { |
| 4402 | + setDockedDraftHeroThreadKey((currentThreadKey) => | |
| 4403 | + currentThreadKey === activeThreadKey ? null : currentThreadKey, | |
| 4404 | + ); | |
| 4237 | 4405 | resetLocalDispatch(); |
| 4238 | 4406 | } |
| 4239 | 4407 | }; |
| @@ -5101,6 +5269,7 @@ function ChatViewContent(props: ChatViewProps) { · 861 unmodified lines | ||
| 5101 | 5269 | contentInsetEndAdjustment={composerOverlayHeight} |
| 5102 | 5270 | onIsAtEndChange={onIsAtEndChange} |
| 5103 | 5271 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} |
| 5272 | + hideEmptyPlaceholder={isDraftHeroState} | |
| 5104 | 5273 | /> |
| 5105 | 5274 | |
| 5106 | 5275 | {/* 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 | ||
| 5123 | 5292 | )} |
| 5124 | 5293 | </div> |
| 5125 | 5294 | |
| 5126 | − {/* Input bar */} | |
| 5295 | + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */} | |
| 5127 | 5296 | <div |
| 5128 | 5297 | ref={setComposerOverlayElement} |
| 5129 | 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 | − <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" /> | |
| 5215 | 5313 | </div> |
| 5216 | 5314 | </div> |
| 5217 | − </div> | |
| 5315 | + ) : null} | |
| 5218 | 5316 | <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" | |
| 5225 | 5319 | > |
| 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> | |
| 5252 | 5474 | </div> |
| 5253 | − )} | |
| 5475 | + </div> | |
| 5254 | 5476 | </div> |
| 5255 | 5477 | </div> |
| 5256 | 5478 | |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | resolveThreadStatusPill, |
| 20 | 20 | shouldClearThreadSelectionOnMouseDown, |
| 21 | 21 | sortProjectsForSidebar, |
| 22 | + sortScopedProjectsForSidebar, | |
| 22 | 23 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 23 | 24 | } from "./Sidebar.logic"; |
| 24 | 25 | import { |
| @@ -1066,3 +1067,73 @@ describe("sortProjectsForSidebar", () => { · 1041 unmodified lines | ||
| 1066 | 1067 | expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); |
| 1067 | 1068 | }); |
| 1068 | 1069 | }); |
| 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 | +}); | |
| @@ -24,6 +24,16 @@ type SidebarProject = { · 23 unmodified lines | ||
| 24 | 24 | updatedAt?: string | undefined; |
| 25 | 25 | }; |
| 26 | 26 | |
| 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 | + | |
| 27 | 37 | export type ThreadTraversalDirection = "previous" | "next"; |
| 28 | 38 | |
| 29 | 39 | export interface ThreadStatusPill { |
| @@ -534,6 +544,25 @@ export function getProjectSortTimestamp( · 504 unmodified lines | ||
| 534 | 544 | return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; |
| 535 | 545 | } |
| 536 | 546 | |
| 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 | + | |
| 537 | 566 | export function sortProjectsForSidebar< |
| 538 | 567 | TProject extends SidebarProject, |
| 539 | 568 | TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput, |
| @@ -542,10 +571,6 @@ export function sortProjectsForSidebar< · 2 unmodified lines | ||
| 542 | 571 | threads: readonly TThread[], |
| 543 | 572 | sortOrder: SidebarProjectSortOrder, |
| 544 | 573 | ): TProject[] { |
| 545 | − if (sortOrder === "manual") { | |
| 546 | − return [...projects]; | |
| 547 | − } | |
| 548 | − | |
| 549 | 574 | const threadsByProjectId = new Map<string, TThread[]>(); |
| 550 | 575 | for (const thread of threads) { |
| 551 | 576 | const existing = threadsByProjectId.get(thread.projectId) ?? []; |
| @@ -553,20 +578,47 @@ export function sortProjectsForSidebar< · 1 unmodified line | ||
| 553 | 578 | threadsByProjectId.set(thread.projectId, existing); |
| 554 | 579 | } |
| 555 | 580 | |
| 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 | + ); | |
| 572 | 624 | } |
| @@ -442,6 +442,8 @@ export interface ChatComposerProps { · 441 unmodified lines | ||
| 442 | 442 | activeThread: Thread | undefined; |
| 443 | 443 | isServerThread: boolean; |
| 444 | 444 | isLocalDraftThread: boolean; |
| 445 | + forceExpandedOnMobile: boolean; | |
| 446 | + projectSelectionRequired: boolean; | |
| 445 | 447 | |
| 446 | 448 | // Session phase |
| 447 | 449 | phase: SessionPhase; |
| @@ -553,6 +555,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 105 unmodified lines | ||
| 553 | 555 | activeThread, |
| 554 | 556 | isServerThread: _isServerThread, |
| 555 | 557 | isLocalDraftThread: _isLocalDraftThread, |
| 558 | + forceExpandedOnMobile, | |
| 559 | + projectSelectionRequired, | |
| 556 | 560 | phase, |
| 557 | 561 | isConnecting, |
| 558 | 562 | isSendBusy, |
| @@ -883,7 +887,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 324 unmodified lines | ||
| 883 | 887 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); |
| 884 | 888 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 885 | 889 | const isMobileViewport = useMediaQuery("max-sm"); |
| 886 | − const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; | |
| 890 | + const isComposerCollapsedMobile = | |
| 891 | + isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; | |
| 887 | 892 | |
| 888 | 893 | // ------------------------------------------------------------------ |
| 889 | 894 | // Refs |
| @@ -1136,7 +1141,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 246 unmodified lines | ||
| 1136 | 1141 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], |
| 1137 | 1142 | ); |
| 1138 | 1143 | const collapsedComposerPrimaryActionDisabled = |
| 1139 | − phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; | |
| 1144 | + phase === "running" || | |
| 1145 | + isSendBusy || | |
| 1146 | + isConnecting || | |
| 1147 | + projectSelectionRequired || | |
| 1148 | + !composerSendState.hasSendableContent; | |
| 1140 | 1149 | const collapsedComposerPrimaryActionLabel = "Send message"; |
| 1141 | 1150 | const showMobilePendingAnswerActions = |
| 1142 | 1151 | isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; |
| @@ -1928,6 +1937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 785 unmodified lines | ||
| 1928 | 1937 | isConnecting || |
| 1929 | 1938 | isComposerApprovalState || |
| 1930 | 1939 | pendingUserInputs.length > 0 || |
| 1940 | + projectSelectionRequired || | |
| 1931 | 1941 | (environmentUnavailable !== null && activePendingProgress === null) |
| 1932 | 1942 | ) { |
| 1933 | 1943 | return false; |
| @@ -2028,6 +2038,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 94 unmodified lines | ||
| 2028 | 2038 | isConnecting, |
| 2029 | 2039 | isComposerApprovalState, |
| 2030 | 2040 | pendingUserInputs.length, |
| 2041 | + projectSelectionRequired, | |
| 2031 | 2042 | environmentUnavailable, |
| 2032 | 2043 | activePendingProgress, |
| 2033 | 2044 | applyPromptReplacement, |
| @@ -2065,9 +2076,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 31 unmodified lines | ||
| 2065 | 2076 | ref={composerSurfaceRef} |
| 2066 | 2077 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} |
| 2067 | 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 | 2080 | isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", |
| 2070 | − environmentUnavailable ? "opacity-75" : null, | |
| 2081 | + environmentUnavailable || projectSelectionRequired ? "opacity-75" : null, | |
| 2071 | 2082 | composerProviderState.composerSurfaceClassName, |
| 2072 | 2083 | )} |
| 2073 | 2084 | onFocusCapture={(event) => { |
| @@ -2179,7 +2190,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 105 unmodified lines | ||
| 2179 | 2190 | promptHasText={false} |
| 2180 | 2191 | isSendBusy={isSendBusy} |
| 2181 | 2192 | isConnecting={isConnecting} |
| 2182 | − isEnvironmentUnavailable={environmentUnavailable !== null} | |
| 2193 | + isEnvironmentUnavailable={ | |
| 2194 | + environmentUnavailable !== null || projectSelectionRequired | |
| 2195 | + } | |
| 2183 | 2196 | isPreparingWorktree={false} |
| 2184 | 2197 | hasSendableContent={false} |
| 2185 | 2198 | preserveComposerFocusOnPointerDown |
| @@ -2413,17 +2426,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 227 unmodified lines | ||
| 2413 | 2426 | ? "Type your own answer, or leave this blank to use the selected option" |
| 2414 | 2427 | : showPlanFollowUpPrompt && activeProposedPlan |
| 2415 | 2428 | ? "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" | |
| 2423 | 2438 | } |
| 2424 | 2439 | disabled={ |
| 2425 | 2440 | isConnecting || |
| 2426 | 2441 | isComposerApprovalState || |
| 2442 | + projectSelectionRequired || | |
| 2427 | 2443 | (environmentUnavailable !== null && activePendingProgress === null) |
| 2428 | 2444 | } |
| 2429 | 2445 | /> |
| @@ -2440,7 +2456,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 10 unmodified lines | ||
| 2440 | 2456 | promptHasText={false} |
| 2441 | 2457 | isSendBusy={isSendBusy} |
| 2442 | 2458 | isConnecting={isConnecting} |
| 2443 | − isEnvironmentUnavailable={environmentUnavailable !== null} | |
| 2459 | + isEnvironmentUnavailable={ | |
| 2460 | + environmentUnavailable !== null || projectSelectionRequired | |
| 2461 | + } | |
| 2444 | 2462 | isPreparingWorktree={false} |
| 2445 | 2463 | hasSendableContent={false} |
| 2446 | 2464 | preserveComposerFocusOnPointerDown |
| @@ -2551,7 +2569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 104 unmodified lines | ||
| 2551 | 2569 | promptHasText={prompt.trim().length > 0} |
| 2552 | 2570 | isSendBusy={isSendBusy} |
| 2553 | 2571 | isConnecting={isConnecting} |
| 2554 | − isEnvironmentUnavailable={environmentUnavailable !== null} | |
| 2572 | + isEnvironmentUnavailable={ | |
| 2573 | + environmentUnavailable !== null || projectSelectionRequired | |
| 2574 | + } | |
| 2555 | 2575 | isPreparingWorktree={isPreparingWorktree} |
| 2556 | 2576 | hasSendableContent={composerSendState.hasSendableContent} |
| 2557 | 2577 | preserveComposerFocusOnPointerDown={isMobileViewport} |
| @@ -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 | +} | |
| @@ -179,6 +179,7 @@ interface MessagesTimelineProps { · 178 unmodified lines | ||
| 179 | 179 | contentInsetEndAdjustment: number; |
| 180 | 180 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 181 | 181 | onManualNavigation: () => void; |
| 182 | + hideEmptyPlaceholder?: boolean; | |
| 182 | 183 | } |
| 183 | 184 | |
| 184 | 185 | // --------------------------------------------------------------------------- |
| @@ -212,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 27 unmodified lines | ||
| 212 | 213 | contentInsetEndAdjustment, |
| 213 | 214 | onIsAtEndChange, |
| 214 | 215 | onManualNavigation, |
| 216 | + hideEmptyPlaceholder = false, | |
| 215 | 217 | }: MessagesTimelineProps) { |
| 216 | 218 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 217 | 219 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); |
| @@ -458,6 +460,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 240 unmodified lines | ||
| 458 | 460 | ); |
| 459 | 461 | |
| 460 | 462 | if (rows.length === 0 && !isWorking) { |
| 463 | + if (hideEmptyPlaceholder) { | |
| 464 | + return null; | |
| 465 | + } | |
| 461 | 466 | return ( |
| 462 | 467 | <div className="flex h-full items-center justify-center"> |
| 463 | 468 | <p className="text-sm text-muted-foreground/30"> |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -47,6 +47,7 @@ export function useNewThreadHandler() { · 46 unmodified lines | ||
| 47 | 47 | worktreePath?: string | null; |
| 48 | 48 | envMode?: DraftThreadEnvMode; |
| 49 | 49 | startFromOrigin?: boolean; |
| 50 | + replace?: boolean; | |
| 50 | 51 | }, |
| 51 | 52 | ): Promise<void> => { |
| 52 | 53 | const { |
| @@ -120,6 +121,7 @@ export function useNewThreadHandler() { · 67 unmodified lines | ||
| 120 | 121 | await router.navigate({ |
| 121 | 122 | to: "/draft/$draftId", |
| 122 | 123 | params: { draftId: reusableStoredDraftThread.draftId }, |
| 124 | + replace: options?.replace ?? false, | |
| 123 | 125 | }); |
| 124 | 126 | })(); |
| 125 | 127 | } |
| @@ -180,6 +182,7 @@ export function useNewThreadHandler() { · 54 unmodified lines | ||
| 180 | 182 | await router.navigate({ |
| 181 | 183 | to: "/draft/$draftId", |
| 182 | 184 | params: { draftId }, |
| 185 | + replace: options?.replace ?? false, | |
| 183 | 186 | }); |
| 184 | 187 | })(); |
| 185 | 188 | }, |
| @@ -4,6 +4,77 @@ · 3 unmodified lines | ||
| 4 | 4 | /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ |
| 5 | 5 | @custom-variant wco (&:is(.wco, .wco *)); |
| 6 | 6 | |
| 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 | + | |
| 7 | 78 | :root { |
| 8 | 79 | --app-scrollbar-width: 6px; |
| 9 | 80 | --desktop-window-right-resize-inset: 0px; |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | useComposerDraftStore, |
| 9 | 9 | } from "../composerDraftStore"; |
| 10 | 10 | import { SidebarInset } from "../components/ui/sidebar"; |
| 11 | +import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition"; | |
| 11 | 12 | import { buildThreadRouteParams } from "../threadRoutes"; |
| 12 | 13 | import { useThread, useThreadRefs } from "../state/entities"; |
| 13 | 14 | |
| @@ -40,11 +41,22 @@ function DraftChatThreadRouteView() { · 26 unmodified lines | ||
| 40 | 41 | if (!canonicalThreadRef) { |
| 41 | 42 | return; |
| 42 | 43 | } |
| 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 | + }); | |
| 47 | 55 | }); |
| 56 | + | |
| 57 | + return () => { | |
| 58 | + cancelled = true; | |
| 59 | + }; | |
| 48 | 60 | }, [canonicalThreadRef, navigate]); |
| 49 | 61 | |
| 50 | 62 | useEffect(() => { |
| @@ -54,18 +66,6 @@ function DraftChatThreadRouteView() { · 3 unmodified lines | ||
| 54 | 66 | void navigate({ to: "/", replace: true }); |
| 55 | 67 | }, [canonicalThreadRef, draftSession, navigate]); |
| 56 | 68 | |
| 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 | 69 | if (!draftSession) { |
| 70 | 70 | return null; |
| 71 | 71 | } |
| @@ -77,6 +77,7 @@ function DraftChatThreadRouteView() { · 5 unmodified lines | ||
| 77 | 77 | environmentId={draftSession.environmentId} |
| 78 | 78 | threadId={draftSession.threadId} |
| 79 | 79 | routeKind="draft" |
| 80 | + forceExpandedMobileComposer | |
| 80 | 81 | /> |
| 81 | 82 | </SidebarInset> |
| 82 | 83 | ); |
| @@ -1,10 +1,19 @@ | ||
| 1 | +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 1 | 2 | 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"; | |
| 3 | 5 | |
| 4 | −import { NoActiveThreadState } from "../components/NoActiveThreadState"; | |
| 6 | +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; | |
| 7 | +import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; | |
| 5 | 8 | import { Button } from "../components/ui/button"; |
| 6 | 9 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; |
| 7 | 10 | import { SidebarInset } from "../components/ui/sidebar"; |
| 11 | +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | |
| 12 | +import { | |
| 13 | + useAllEnvironmentShellsBootstrapped, | |
| 14 | + useProjects, | |
| 15 | + useThreadShells, | |
| 16 | +} from "../state/entities"; | |
| 8 | 17 | import { useEnvironments } from "../state/environments"; |
| 9 | 18 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 10 | 19 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| @@ -19,7 +28,109 @@ function ChatIndexRouteView() { · 8 unmodified lines | ||
| 19 | 28 | return <HostedStaticOnboardingState />; |
| 20 | 29 | } |
| 21 | 30 | |
| 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 | + ); | |
| 23 | 134 | } |
| 24 | 135 | |
| 25 | 136 | export const Route = createFileRoute("/_chat/")({ |
| @@ -20,6 +20,7 @@ import { useMemo } from "react"; · 19 unmodified lines | ||
| 20 | 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 21 | 21 | import { environmentProjects } from "./projects"; |
| 22 | 22 | import { environmentServerConfigsAtom } from "./server"; |
| 23 | +import { allEnvironmentShellsBootstrappedAtom } from "./shell"; | |
| 23 | 24 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; |
| 24 | 25 | |
| 25 | 26 | const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); |
| @@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> { · 87 unmodified lines | ||
| 113 | 114 | return useAtomValue(environmentThreadShells.threadShellsAtom); |
| 114 | 115 | } |
| 115 | 116 | |
| 117 | +export function useAllEnvironmentShellsBootstrapped(): boolean { | |
| 118 | + return useAtomValue(allEnvironmentShellsBootstrappedAtom); | |
| 119 | +} | |
| 120 | + | |
| 116 | 121 | export function useThreadShellsForProjectRefs( |
| 117 | 122 | refs: ReadonlyArray<ScopedProjectRef>, |
| 118 | 123 | ): ReadonlyArray<EnvironmentThreadShell> { |
| @@ -1,9 +1,15 @@ | ||
| 1 | +import { | |
| 2 | + AVAILABLE_CONNECTION_STATE, | |
| 3 | + connectionProjectionPhase, | |
| 4 | +} from "@t3tools/client-runtime/connection"; | |
| 1 | 5 | import { |
| 2 | 6 | createEnvironmentShellAtoms, |
| 3 | 7 | createEnvironmentShellSummaryAtom, |
| 4 | 8 | createEnvironmentSnapshotAtom, |
| 5 | 9 | createShellEnvironmentAtoms, |
| 6 | 10 | } from "@t3tools/client-runtime/state/shell"; |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 7 | 13 | |
| 8 | 14 | import { environmentCatalog } from "../connection/catalog"; |
| 9 | 15 | import { connectionAtomRuntime } from "../connection/runtime"; |
| @@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ · 5 unmodified lines | ||
| 15 | 21 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 16 | 22 | shellStateValueAtom: environmentShell.stateValueAtom, |
| 17 | 23 | }); |
| 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")); | |