Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
import {
selectEnvironmentState,
selectProjectsAcrossEnvironments,
selectThreadsAcrossEnvironments,
selectWorkspaceProjectsAcrossEnvironments,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<FirstRunSetupCard
providers={providerInstanceEntries}
projectName={firstRunProject?.name ?? null}
projectCwd={firstRunProject?.cwd ?? null}
projectEnvironmentId={firstRunProject?.environmentId ?? environmentId}
isOnlyWorkspaceProject={firstRunWorkspaceProjects.length === 1}
onSignIn={(row) => {
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) {
Expand Down Expand Up @@ -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) });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -6368,7 +6456,7 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */}
<MessagesTimeline
key={activeThread.id}
emptyState={draftTimelineEmptyState}
emptyState={firstRunSetupEmptyState ?? draftTimelineEmptyState}
isWorking={isWorking}
activeStatusLabel={activeStatusLabel}
activeTurnInProgress={isWorking || !latestTurnSettled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ describe("provider update launch notification logic", () => {
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.",
});
});

Expand All @@ -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", () => {
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ export function formatProviderList(providers: ReadonlyArray<Pick<ServerProvider,
return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
}

/**
* Like `formatProviderList` but with each provider's target version, so the
* multi-provider update card says what it will install without growing past
* one line: "Claude v2.1.230 and Codex v0.146.1".
*/
export function formatProviderUpdateList(providers: ReadonlyArray<ProviderUpdateCandidate>) {
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<ProviderUpdateCandidate>;
readonly oneClickProviders: ReadonlyArray<ProviderUpdateCandidate>;
Expand All @@ -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.`,
};
}

Expand Down
22 changes: 19 additions & 3 deletions apps/web/src/components/ThreadlinesFigure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div aria-hidden="true" className="no-thread-rise relative mb-7" style={riseDelay("0.05s")}>
<div
aria-hidden="true"
className={compact ? "no-thread-rise relative mb-4" : "no-thread-rise relative mb-7"}
style={riseDelay("0.05s")}
>
<div className="pointer-events-none absolute -inset-x-14 -inset-y-8 rounded-full bg-primary-graph/[0.05] blur-2xl dark:bg-primary-graph/[0.07]" />
<svg
className="relative h-auto w-[300px] sm:w-[336px] lg:w-[384px]"
className={
compact
? "relative h-auto w-[200px] sm:w-[224px]"
: "relative h-auto w-[300px] sm:w-[336px] lg:w-[384px]"
}
fill="none"
viewBox="0 0 360 120"
>
Expand Down
Loading
Loading