diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1ae71af0..342d542b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,6 +89,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { + selectEnvironmentState, selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, selectWorkspaceProjectsAcrossEnvironments, @@ -193,6 +194,9 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline, type TimelineProposedPlanState } from "./chat/MessagesTimeline"; import { DraftEmptyState } from "./chat/DraftEmptyState"; +import { FirstRunSetupCard, useFirstRunSetupDismissal } from "./chat/FirstRunSetupCard"; +import { shouldShowFirstRunSetupCard } from "./chat/firstRunSetup"; +import { isHostedStaticApp } from "../hostedPairing"; import { ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ChatHeader, type ForkHeaderContext } from "./chat/ChatHeader"; import type { DesktopPreviewPickedElement } from "@threadlines/contracts"; @@ -3359,6 +3363,80 @@ export default function ChatView(props: ChatViewProps) { [activeProviderDriver, activeProviderLabel, providerAuthReconnectPrompt, runProjectScript], ); + // --- First-run setup card ------------------------------------------------- + // A cold install has no threads and no guidance, so the draft thread's empty + // state becomes the setup checklist until the user sends something or skips. + // Hosted phone/browser sessions are excluded: they have their own pairing + // states and no local provider they could sign in to from here. + const isHostedStaticSurface = useMemo(() => isHostedStaticApp(), []); + const { isDismissed: isFirstRunSetupDismissed, dismiss: dismissFirstRunSetupForEnvironment } = + useFirstRunSetupDismissal(draftThread?.environmentId ?? environmentId); + const hasUserMessagedThread = useStore((state) => { + const environmentState = selectEnvironmentState(state, environmentId); + return environmentState.threadIds.some( + (candidateThreadId) => + environmentState.sidebarThreadSummaryById[candidateThreadId]?.latestUserMessageAt != null, + ); + }); + const isEnvironmentBootstrapComplete = useStore( + (state) => selectEnvironmentState(state, environmentId).bootstrapComplete, + ); + const showFirstRunSetupCard = shouldShowFirstRunSetupCard({ + isHostedStatic: isHostedStaticSurface, + isDraftThread: isLocalDraftThread && draftThread !== undefined, + isGeneralChat: isGeneralChatThread, + bootstrapComplete: isEnvironmentBootstrapComplete, + hasUserMessagedThread, + isDismissed: isFirstRunSetupDismissed, + }); + const firstRunWorkspaceProjects = useMemo( + () => allProjects.filter((project) => project.kind !== "general-chat"), + [allProjects], + ); + // A reloaded draft thread has no project bound yet (`activeProject` is + // null until the first send), but the bootstrapped workspace project is + // already in the project list; the card must not claim "No folder yet" + // while the sidebar shows one. General Chat can't be the active project + // here because the card never renders on General Chat drafts. + const firstRunProject = activeProject ?? firstRunWorkspaceProjects[0] ?? null; + const firstRunSetupEmptyState = useMemo(() => { + if (!showFirstRunSetupCard) { + return undefined; + } + return ( + { + if (!row.signInCommand) return; + void runProviderAuthReconnect({ + provider: row.driverKind, + command: row.signInCommand, + message: `${row.name} is not signed in.`, + }); + }} + onChooseProject={() => useCommandPaletteStore.getState().openAddProject()} + onSkip={dismissFirstRunSetupForEnvironment} + onStart={() => { + dismissFirstRunSetupForEnvironment(); + scheduleComposerFocus(); + }} + /> + ); + }, [ + dismissFirstRunSetupForEnvironment, + environmentId, + firstRunProject, + firstRunWorkspaceProjects.length, + providerInstanceEntries, + runProviderAuthReconnect, + scheduleComposerFocus, + showFirstRunSetupCard, + ]); + const runMcpAuthReconnect = useCallback( async (action: McpAuthReconnectAction) => { if (action.provider !== CODEX_PROVIDER_DRIVER) { @@ -4436,6 +4514,12 @@ export default function ChatView(props: ChatViewProps) { return; } + // Sending is completing setup: the card has served its purpose and must + // not reappear behind the conversation the user just started. + if (showFirstRunSetupCard) { + dismissFirstRunSetupForEnvironment(); + } + sendInFlightRef.current = true; if (!isSteeringFollowUp) { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); @@ -5156,9 +5240,13 @@ export default function ChatView(props: ChatViewProps) { const providerStatusNotice = useProviderStatusNotice({ status: activeProviderStatus, activeTurnInProgress, + // The held-send notice and the setup card each already state this + // provider's problem with the actions that fix it; a second ambient row + // saying it again is the stacking noise the dock exists to end. suppressed: - providerSendPreflight !== null && - providerSendPreflight.instanceId === activeProviderStatus?.instanceId, + showFirstRunSetupCard || + (providerSendPreflight !== null && + providerSendPreflight.instanceId === activeProviderStatus?.instanceId), }); const sessionStartupNotice = useSessionStartupNotice({ isSessionStarting, @@ -6368,7 +6456,7 @@ export default function ChatView(props: ChatViewProps) { {/* Messages — LegendList handles virtualization and scrolling internally */} { phase: "initial", type: "warning", title: "Updates available", - description: "Codex and Claude can be updated.", + description: "Codex v1.1.0 and Claude v2.1.197 can be updated.", }); }); @@ -315,7 +315,9 @@ describe("provider update launch notification logic", () => { oneClickProviders: [], }); - expect(view.description).toBe("Codex and Cursor can be updated from provider settings."); + expect(view.description).toBe( + "Codex v1.1.0 and Cursor v1.1.0 can be updated from provider settings.", + ); }); it("uses server update state for running progress", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 840dfc48..572b1836 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -235,6 +235,22 @@ export function formatProviderList(providers: ReadonlyArray) { + const names = providers.map( + (provider) => + `${getProviderDisplayName(provider)} ${formatVersion(provider.versionAdvisory.latestVersion)}`, + ); + if (names.length <= 2) { + return names.join(" and "); + } + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +} + export function getProviderUpdateInitialToastView(input: { readonly updateProviders: ReadonlyArray; readonly oneClickProviders: ReadonlyArray; @@ -247,9 +263,9 @@ export function getProviderUpdateInitialToastView(input: { description: input.oneClickProviders.length > 0 ? hasMultipleProviders - ? `${formatProviderList(input.updateProviders)} can be updated.` + ? `${formatProviderUpdateList(input.updateProviders)} can be updated.` : "Install the update now or review provider settings." - : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`, + : `${formatProviderUpdateList(input.updateProviders)} can be updated from provider settings.`, }; } diff --git a/apps/web/src/components/ThreadlinesFigure.tsx b/apps/web/src/components/ThreadlinesFigure.tsx index 30a4a97d..e4f70fa4 100644 --- a/apps/web/src/components/ThreadlinesFigure.tsx +++ b/apps/web/src/components/ThreadlinesFigure.tsx @@ -6,12 +6,28 @@ export function riseDelay(delay: string): React.CSSProperties { /* Decorative thread graph: branches draw themselves in, commits surface left-to-right, and the one still-open branch ends on a live accent node. */ -export function ThreadlinesFigure() { +export function ThreadlinesFigure({ + /** + * Two-thirds scale with tighter margins, for surfaces that put content + * under the figure (the setup card) rather than existing around it. + */ + compact = false, +}: { + compact?: boolean; +} = {}) { return ( -