From 3406b9f12f8dd1312108ab468ac5559e2c393e60 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:30:05 -0400 Subject: [PATCH 1/5] Show a first-run setup card on cold installs Implements section 1 of the approved onboarding mockup (docs/design/onboarding-setup-card.html): a brand-new install's empty draft thread now opens on a boxless, hairline-divided setup card instead of the bare "What's next" prompt. One live row per provider (settings status language, dot flips green while the sign-in terminal is open, Sign in runs the same auth flow the composer notice uses, Install guide routes to provider settings), a project row for the bootstrapped launch folder, and Start first thread that enables once one provider is usable and a project exists. Shows only on a genuine cold start: local draft thread, not General Chat, not the hosted surface, environment bootstrapped, no user message ever sent in the environment. Skip for now, Start first thread, and any send dismiss it permanently per environment (schema-validated localStorage, versionSkew dismissal pattern). While the card is visible the ambient provider-status notice is suppressed; the card already states the same problem with the same fix actions. --- apps/web/src/components/ChatView.tsx | 87 +++++- .../chat/FirstRunSetupCard.browser.tsx | 219 ++++++++++++++ .../src/components/chat/FirstRunSetupCard.tsx | 246 +++++++++++++++ .../src/components/chat/firstRunSetup.test.ts | 198 ++++++++++++ apps/web/src/components/chat/firstRunSetup.ts | 286 ++++++++++++++++++ 5 files changed, 1033 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/FirstRunSetupCard.browser.tsx create mode 100644 apps/web/src/components/chat/FirstRunSetupCard.tsx create mode 100644 apps/web/src/components/chat/firstRunSetup.test.ts create mode 100644 apps/web/src/components/chat/firstRunSetup.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1ae71af0..44b50b2d 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,73 @@ 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 workspaceProjectCount = useMemo( + () => allProjects.filter((project) => project.kind !== "general-chat").length, + [allProjects], + ); + 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(); + }} + /> + ); + }, [ + activeProject?.cwd, + activeProject?.name, + dismissFirstRunSetupForEnvironment, + providerInstanceEntries, + runProviderAuthReconnect, + scheduleComposerFocus, + showFirstRunSetupCard, + workspaceProjectCount, + ]); + const runMcpAuthReconnect = useCallback( async (action: McpAuthReconnectAction) => { if (action.provider !== CODEX_PROVIDER_DRIVER) { @@ -4436,6 +4507,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 +5233,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 +6449,7 @@ export default function ChatView(props: ChatViewProps) { {/* Messages — LegendList handles virtualization and scrolling internally */} ; + readonly projectName: string | null; + readonly onSignIn?: (row: FirstRunProviderRow) => void; + readonly onChooseProject?: () => void; + readonly onSkip?: () => void; + readonly onStart?: () => void; +}) { + const rootRoute = createRootRoute({ + component: () => ( + + ), + }); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + const providersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/settings/providers", + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, providersRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + + return render(); +} + +function rowStates(): Record { + return Object.fromEntries( + Array.from(document.querySelectorAll("[data-testid='first-run-setup-row']")).map((row) => [ + row.getAttribute("data-row-id") ?? "", + row.getAttribute("data-row-state") ?? "", + ]), + ); +} + +describe("FirstRunSetupCard", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("gives every provider state its own dot and action, and holds the start button back", async () => { + const onSignIn = vi.fn(); + const screen = await renderCard({ + providers: [SIGNED_OUT_CODEX, MISSING_CLAUDE], + projectName: "B-git-project", + onSignIn, + }); + + expect(rowStates()).toEqual({ + codex: "needsSignIn", + claudeAgent: "notInstalled", + project: "ready", + }); + + const dots = Array.from(document.querySelectorAll("[data-testid='first-run-setup-dot']")); + expect(dots.map((dot) => dot.getAttribute("data-row-state"))).toEqual([ + "needsSignIn", + "notInstalled", + "ready", + ]); + // Amber for a fixable sign-in, red for a missing CLI, green for the folder. + expect(dots.map((dot) => dot.className)).toEqual([ + expect.stringContaining("bg-warning"), + expect.stringContaining("bg-destructive"), + expect.stringContaining("bg-success"), + ]); + + await expect + .element(page.getByText("B-git-project · the folder you launched from")) + .toBeVisible(); + expect( + document.querySelector('a[href="/settings/providers"]')?.textContent, + ).toContain("Install guide"); + + await page.getByRole("button", { name: "Sign in to Codex" }).click(); + expect(onSignIn).toHaveBeenCalledTimes(1); + expect(onSignIn.mock.calls[0]?.[0]).toMatchObject({ + name: "Codex", + signInCommand: "codex login", + }); + + await expect.element(page.getByRole("button", { name: "Start first thread" })).toBeDisabled(); + + await screen.unmount(); + }); + + it("enables the start action once one agent is signed in and a folder exists", async () => { + const onStart = vi.fn(); + const onSkip = vi.fn(); + const screen = await renderCard({ + providers: [SIGNED_OUT_CODEX, SIGNED_IN_CLAUDE], + projectName: "B-git-project", + onStart, + onSkip, + }); + + expect(rowStates()).toMatchObject({ claudeAgent: "ready" }); + await expect.element(page.getByText("Signed in · Claude Max")).toBeVisible(); + + await page.getByRole("button", { name: "Start first thread" }).click(); + expect(onStart).toHaveBeenCalledTimes(1); + + await page.getByRole("button", { name: "Skip for now" }).click(); + expect(onSkip).toHaveBeenCalledTimes(1); + + await screen.unmount(); + }); + + it("asks for a folder, and stays disabled, when no project exists", async () => { + const onChooseProject = vi.fn(); + const screen = await renderCard({ + providers: [SIGNED_IN_CLAUDE], + projectName: null, + onChooseProject, + }); + + expect(rowStates()).toMatchObject({ project: "missing" }); + + await page.getByRole("button", { name: "Choose a folder" }).click(); + expect(onChooseProject).toHaveBeenCalledTimes(1); + + await expect.element(page.getByRole("button", { name: "Start first thread" })).toBeDisabled(); + + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx new file mode 100644 index 00000000..7a9ae34f --- /dev/null +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -0,0 +1,246 @@ +/** + * The first thing a brand-new install sees, in place of the empty draft + * thread's usual "What's next in ...?" prompt. + * + * It is the same provider data the settings page shows, with the fix action on + * the row instead of two clicks away, plus the folder the server bootstrapped + * from. Rows are live: provider snapshots stream in over providers-updated + * events, so a dot flips from amber to green while the sign-in terminal is + * still open, and "Start first thread" enables at the same moment. + * + * No container: typography, spacing, and hairline dividers on the empty + * canvas, matching the rest of the app. + * + * @module FirstRunSetupCard + */ +import type { EnvironmentId } from "@threadlines/contracts"; +import { Link } from "@tanstack/react-router"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { + buildFirstRunSetupDismissalKey, + canStartFirstThread, + deriveFirstRunProjectRow, + deriveFirstRunProviderRows, + dismissFirstRunSetup, + isFirstRunSetupDismissed, + type FirstRunProviderRow, + type FirstRunSetupProvider, +} from "./firstRunSetup"; + +/** + * Reads the environment's dismissal once and re-reads it after this hook + * writes one, so "Skip for now" hides the card in the same tick. Callers that + * dismiss on another path (a send) go through the returned `dismiss` too, + * which keeps the render in step with storage. + */ +export function useFirstRunSetupDismissal(environmentId: EnvironmentId | null | undefined): { + readonly isDismissed: boolean; + readonly dismiss: () => void; +} { + const dismissalKey = environmentId ? buildFirstRunSetupDismissalKey(environmentId) : null; + const [dismissedKeys, setDismissedKeys] = useState>(() => + dismissalKey !== null && isFirstRunSetupDismissed(dismissalKey) ? [dismissalKey] : [], + ); + + const dismiss = useCallback(() => { + if (dismissalKey === null) { + return; + } + dismissFirstRunSetup(dismissalKey); + setDismissedKeys((current) => + current.includes(dismissalKey) ? current : [...current, dismissalKey], + ); + }, [dismissalKey]); + + // Re-read storage when the key changes (environment switch) rather than on + // every render: this hook lives in the chat view's render path. + const isDismissed = useMemo( + () => + dismissalKey !== null && + (dismissedKeys.includes(dismissalKey) || isFirstRunSetupDismissed(dismissalKey)), + [dismissalKey, dismissedKeys], + ); + + return { isDismissed, dismiss }; +} + +function SetupRow({ + rowId, + state, + dotClassName, + name, + versionLabel, + description, + action, +}: { + rowId: string; + state: string; + dotClassName: string; + name: string; + versionLabel?: string | null; + description: string; + action: ReactNode; +}) { + return ( +
  • + + {name} + {versionLabel ? ( + + {versionLabel} + + ) : null} + {description} + {action} +
  • + ); +} + +function providerRowAction( + row: FirstRunProviderRow, + onSignIn: (row: FirstRunProviderRow) => void, +): ReactNode { + if (row.state === "ready") { + return null; + } + if (row.state === "needsSignIn" && row.signInCommand) { + return ( + + ); + } + return ( + + ); +} + +export interface FirstRunSetupCardProps { + /** Enabled and disabled instances alike; disabled ones are filtered out. */ + readonly providers: ReadonlyArray; + readonly projectName: string | null; + readonly projectCwd: string | null; + /** True when this is the workspace's only project, i.e. the launch folder. */ + readonly isOnlyWorkspaceProject: boolean; + readonly onSignIn: (row: FirstRunProviderRow) => void; + readonly onChooseProject: () => void; + readonly onSkip: () => void; + readonly onStart: () => void; +} + +export function FirstRunSetupCard({ + providers, + projectName, + projectCwd, + isOnlyWorkspaceProject, + onSignIn, + onChooseProject, + onSkip, + onStart, +}: FirstRunSetupCardProps) { + const providerRows = useMemo(() => deriveFirstRunProviderRows(providers), [providers]); + const projectRow = useMemo( + () => deriveFirstRunProjectRow({ projectName, projectCwd, isOnlyWorkspaceProject }), + [isOnlyWorkspaceProject, projectCwd, projectName], + ); + const canStart = canStartFirstThread({ providerRows, projectRow }); + + return ( +
    +

    + Set up Threadlines +

    +

    + Connect a coding agent and pick a folder. Rows update live as you go. +

    + +
      + {providerRows.map((row) => ( + + ))} + + {projectRow.actionLabel} + + ) : ( + + ) + } + /> +
    + +
    + + You can start once one agent is signed in. + + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/chat/firstRunSetup.test.ts b/apps/web/src/components/chat/firstRunSetup.test.ts new file mode 100644 index 00000000..8801fa0b --- /dev/null +++ b/apps/web/src/components/chat/firstRunSetup.test.ts @@ -0,0 +1,198 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@threadlines/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { removeLocalStorageItem } from "../../hooks/useLocalStorage"; +import { + buildFirstRunSetupDismissalKey, + canStartFirstThread, + deriveFirstRunProjectRow, + deriveFirstRunProviderRows, + dismissFirstRunSetup, + FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY, + isFirstRunSetupDismissed, + shouldShowFirstRunSetupCard, + type FirstRunSetupProvider, +} from "./firstRunSetup"; + +function provider(overrides: { + readonly instanceId: string; + readonly driver: string; + readonly displayName: string; + readonly enabled?: boolean; + readonly installed?: boolean; + readonly version?: string | null; + readonly auth?: ServerProvider["auth"]; + readonly message?: string; +}): FirstRunSetupProvider { + const driverKind = ProviderDriverKind.make(overrides.driver); + const snapshot: ServerProvider = { + driver: driverKind, + instanceId: ProviderInstanceId.make(overrides.instanceId), + displayName: overrides.displayName, + enabled: overrides.enabled ?? true, + installed: overrides.installed ?? true, + version: overrides.version ?? null, + status: "ready", + auth: overrides.auth ?? { status: "authenticated" }, + checkedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + slashCommands: [], + skills: [], + models: [], + ...(overrides.message ? { message: overrides.message } : {}), + }; + return { + instanceId: snapshot.instanceId, + driverKind, + displayName: overrides.displayName, + enabled: snapshot.enabled, + snapshot, + }; +} + +const signedInCodex = provider({ + instanceId: "codex", + driver: "codex", + displayName: "Codex", + version: "0.146.1", + auth: { status: "authenticated", label: "ChatGPT Plus Subscription" }, +}); + +const signedOutCodex = provider({ + instanceId: "codex", + driver: "codex", + displayName: "Codex", + version: "0.146.1", + auth: { status: "unauthenticated" }, +}); + +const missingClaude = provider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + displayName: "Claude", + installed: false, + auth: { status: "unknown" }, +}); + +const READY_PROJECT_ROW = deriveFirstRunProjectRow({ + projectName: "B-git-project", + projectCwd: "C:/code/B-git-project", + isOnlyWorkspaceProject: true, +}); +const MISSING_PROJECT_ROW = deriveFirstRunProjectRow({ + projectName: null, + projectCwd: null, + isOnlyWorkspaceProject: false, +}); + +const SHOWN_INPUT = { + isHostedStatic: false, + isDraftThread: true, + isGeneralChat: false, + bootstrapComplete: true, + hasUserMessagedThread: false, + isDismissed: false, +} as const; + +describe("shouldShowFirstRunSetupCard", () => { + it("shows on a cold draft thread that has never been messaged", () => { + expect(shouldShowFirstRunSetupCard(SHOWN_INPUT)).toBe(true); + }); + + it("hides once any thread in the environment carries a user message", () => { + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, hasUserMessagedThread: true })).toBe( + false, + ); + }); + + it("hides once dismissed", () => { + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isDismissed: true })).toBe(false); + }); + + it("never renders on hosted phone surfaces, general chat, or a server thread", () => { + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isHostedStatic: true })).toBe(false); + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isGeneralChat: true })).toBe(false); + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isDraftThread: false })).toBe(false); + }); + + it("waits for the environment bootstrap before deciding", () => { + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, bootstrapComplete: false })).toBe(false); + }); +}); + +describe("first-run setup dismissal", () => { + beforeEach(() => { + removeLocalStorageItem(FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY); + }); + + it("persists per environment", () => { + const local = buildFirstRunSetupDismissalKey(EnvironmentId.make("environment-local")); + const remote = buildFirstRunSetupDismissalKey(EnvironmentId.make("environment-remote")); + + expect(isFirstRunSetupDismissed(local)).toBe(false); + + dismissFirstRunSetup(local); + + expect(isFirstRunSetupDismissed(local)).toBe(true); + expect(isFirstRunSetupDismissed(remote)).toBe(false); + }); +}); + +describe("deriveFirstRunProviderRows", () => { + it("routes each provider state to its own dot, copy, and action affordance", () => { + const rows = deriveFirstRunProviderRows([signedOutCodex, missingClaude, signedInCodex]); + + expect(rows.map((row) => row.state)).toEqual(["needsSignIn", "notInstalled", "ready"]); + expect(rows[0]?.signInCommand).toBe("codex login"); + expect(rows[0]?.versionLabel).toBe("v0.146.1"); + expect(rows[0]?.description).toContain("Not authenticated"); + // Nothing to sign in to yet, so the row must not offer a login command. + expect(rows[1]?.signInCommand).toBeNull(); + expect(rows[1]?.description).toContain("CLI not detected on PATH"); + expect(rows[2]?.description).toBe("Signed in · ChatGPT Plus Subscription"); + expect(new Set(rows.map((row) => row.dotClassName)).size).toBe(3); + }); + + it("leaves out instances the user disabled", () => { + const rows = deriveFirstRunProviderRows([{ ...signedInCodex, enabled: false }, missingClaude]); + + expect(rows.map((row) => row.name)).toEqual(["Claude"]); + }); +}); + +describe("deriveFirstRunProjectRow", () => { + it("claims the launch folder only for the workspace's sole project", () => { + expect(READY_PROJECT_ROW.description).toBe("B-git-project · the folder you launched from"); + expect( + deriveFirstRunProjectRow({ + projectName: "other", + projectCwd: "C:/code/other", + isOnlyWorkspaceProject: false, + }).description, + ).toBe("other · C:/code/other"); + }); + + it("asks for a folder when there is none", () => { + expect(MISSING_PROJECT_ROW.state).toBe("missing"); + expect(MISSING_PROJECT_ROW.actionLabel).toBe("Choose a folder"); + }); +}); + +describe("canStartFirstThread", () => { + it("stays disabled until a usable provider and a project both exist", () => { + const unusable = deriveFirstRunProviderRows([signedOutCodex, missingClaude]); + const usable = deriveFirstRunProviderRows([signedOutCodex, signedInCodex]); + + expect(canStartFirstThread({ providerRows: unusable, projectRow: READY_PROJECT_ROW })).toBe( + false, + ); + expect(canStartFirstThread({ providerRows: usable, projectRow: MISSING_PROJECT_ROW })).toBe( + false, + ); + expect(canStartFirstThread({ providerRows: usable, projectRow: READY_PROJECT_ROW })).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/firstRunSetup.ts b/apps/web/src/components/chat/firstRunSetup.ts new file mode 100644 index 00000000..85166c5f --- /dev/null +++ b/apps/web/src/components/chat/firstRunSetup.ts @@ -0,0 +1,286 @@ +/** + * First-run setup card: what it says, when it shows, and when it stops. + * + * A brand-new install lands on an empty draft thread with no guidance, even + * though the client already holds everything needed to tell the user what is + * missing: the provider snapshots Settings renders, and the project the server + * bootstrapped from the launch folder. This module turns those into the rows + * the card draws, decides whether the card belongs on screen at all, and owns + * the per-environment dismissal record. + * + * Status language is borrowed from the settings page (`getProviderSummary`) + * and the availability verdict from the model picker + * (`getModelPickerProviderAvailability`), so a provider is never described one + * way here and another way two clicks later. + * + * @module firstRunSetup + */ +import type { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ServerProvider, +} from "@threadlines/contracts"; +import { providerAuthReconnectCommand } from "@threadlines/shared/providerAuth"; +import * as Schema from "effect/Schema"; + +import { + getLocalStorageItemWithLegacyKeys, + setLocalStorageItem, +} from "../../hooks/useLocalStorage"; +import { + getProviderSummary, + getProviderVersionLabel, + PROVIDER_STATUS_STYLES, +} from "../settings/providerStatus"; +import { getModelPickerProviderAvailability } from "./modelPickerEmptyState"; + +export const FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY = "threadlines:first-run-setup-dismissals:v1"; +/** + * Empty today: this key was minted after the t3code rename, so nothing + * predates it. Kept as the same shape the other dismissal records use so a + * future key bump migrates without restructuring the reader. + */ +const LEGACY_FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEYS: readonly string[] = []; + +const FirstRunSetupDismissalsSchema = Schema.Struct({ + keys: Schema.Array(Schema.String), +}); + +type FirstRunSetupDismissals = typeof FirstRunSetupDismissalsSchema.Type; + +export function buildFirstRunSetupDismissalKey(environmentId: EnvironmentId): string { + return String(environmentId); +} + +function readFirstRunSetupDismissals(): FirstRunSetupDismissals { + try { + return ( + getLocalStorageItemWithLegacyKeys( + FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY, + LEGACY_FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEYS, + FirstRunSetupDismissalsSchema, + ) ?? { keys: [] } + ); + } catch { + return { keys: [] }; + } +} + +function writeFirstRunSetupDismissals(document: FirstRunSetupDismissals): void { + try { + setLocalStorageItem( + FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY, + document, + FirstRunSetupDismissalsSchema, + ); + } catch { + // Dismissal state is best-effort UI state; a storage failure must not + // block the surface the user just asked to leave. + } +} + +export function isFirstRunSetupDismissed(dismissalKey: string | null | undefined): boolean { + if (!dismissalKey) { + return false; + } + return readFirstRunSetupDismissals().keys.includes(dismissalKey); +} + +export function dismissFirstRunSetup(dismissalKey: string | null | undefined): void { + if (!dismissalKey) { + return; + } + const document = readFirstRunSetupDismissals(); + if (document.keys.includes(dismissalKey)) { + return; + } + writeFirstRunSetupDismissals({ keys: [...document.keys, dismissalKey] }); +} + +/** + * The card takes over the draft thread's empty state only on a genuine cold + * start. + * + * Every clause is a reason the card would be noise: a hosted phone has its own + * pairing surface and no local provider to fix; General Chat has no project + * row to speak of; an environment still bootstrapping has not told us whether + * it has threads; an environment where someone has already sent a message is + * not a first run; and a dismissal is permanent. + */ +export function shouldShowFirstRunSetupCard(input: { + readonly isHostedStatic: boolean; + readonly isDraftThread: boolean; + readonly isGeneralChat: boolean; + readonly bootstrapComplete: boolean; + readonly hasUserMessagedThread: boolean; + readonly isDismissed: boolean; +}): boolean { + return ( + input.isDraftThread && + !input.isHostedStatic && + !input.isGeneralChat && + input.bootstrapComplete && + !input.hasUserMessagedThread && + !input.isDismissed + ); +} + +/** What the user has to do next about one provider, if anything. */ +export type FirstRunProviderRowState = "ready" | "needsSignIn" | "notInstalled"; + +/** + * The slice of a `ProviderInstanceEntry` a row needs. Declared structurally so + * entries pass through unchanged and tests can build a minimal fixture. + */ +export interface FirstRunSetupProvider { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly displayName: string; + readonly enabled: boolean; + readonly snapshot: ServerProvider; +} + +export interface FirstRunProviderRow { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly name: string; + readonly versionLabel: string | null; + readonly description: string; + readonly state: FirstRunProviderRowState; + readonly dotClassName: string; + /** + * Terminal login command for this driver. Null when the driver has none, in + * which case the row falls back to the install guide rather than offering a + * sign-in that cannot run. + */ + readonly signInCommand: string | null; +} + +const PROVIDER_ROW_DOT_CLASS_NAMES: Record = { + ready: PROVIDER_STATUS_STYLES.ready.dot, + needsSignIn: PROVIDER_STATUS_STYLES.warning.dot, + notInstalled: PROVIDER_STATUS_STYLES.error.dot, +}; + +function providerRowState(snapshot: ServerProvider): FirstRunProviderRowState { + switch (getModelPickerProviderAvailability(snapshot)) { + case "available": + return "ready"; + case "notInstalled": + return "notInstalled"; + case "notAuthenticated": + return "needsSignIn"; + } +} + +/** + * One line under the provider name. A signed-in provider names the account + * plan the snapshot reported and nothing more (we never invent account + * details); everything else reuses the settings page's headline and detail so + * the two surfaces agree, with an action clause when the server gave no + * detail of its own. + */ +function providerRowDescription( + provider: FirstRunSetupProvider, + state: FirstRunProviderRowState, +): string { + if (state === "ready") { + const authLabel = provider.snapshot.auth.label ?? provider.snapshot.auth.type ?? null; + return authLabel ? `Signed in · ${authLabel}` : "Signed in"; + } + + const summary = getProviderSummary(provider.snapshot); + const detail = summary.detail?.trim(); + if (detail) { + return `${summary.headline}. ${detail}`; + } + return state === "notInstalled" + ? `${summary.headline}. Install ${provider.displayName}, then sign in.` + : `${summary.headline}. Sign in to use ${provider.displayName} here.`; +} + +/** + * One row per enabled provider instance, in the order the caller supplied + * (settings order). Disabled instances are left out: the user turned them off, + * so they are not part of getting started. + */ +export function deriveFirstRunProviderRows( + providers: ReadonlyArray, +): ReadonlyArray { + return providers + .filter((provider) => provider.enabled) + .map((provider) => { + const state = providerRowState(provider.snapshot); + return { + instanceId: provider.instanceId, + driverKind: provider.driverKind, + name: provider.displayName, + versionLabel: getProviderVersionLabel(provider.snapshot.version), + description: providerRowDescription(provider, state), + state, + dotClassName: PROVIDER_ROW_DOT_CLASS_NAMES[state], + signInCommand: + state === "needsSignIn" + ? (providerAuthReconnectCommand(provider.driverKind) ?? null) + : null, + } satisfies FirstRunProviderRow; + }); +} + +export type FirstRunProjectRowState = "ready" | "missing"; + +export interface FirstRunProjectRow { + readonly state: FirstRunProjectRowState; + readonly description: string; + readonly dotClassName: string; + readonly actionLabel: string; +} + +/** + * The folder row. "The folder you launched from" is only claimed when this is + * the workspace's one and only project, which is exactly the shape the + * server's launch-folder bootstrap leaves behind; anything else shows the path + * instead of asserting something we cannot check from the client. + */ +export function deriveFirstRunProjectRow(input: { + readonly projectName: string | null | undefined; + readonly projectCwd: string | null | undefined; + readonly isOnlyWorkspaceProject: boolean; +}): FirstRunProjectRow { + const name = input.projectName?.trim(); + if (!name) { + return { + state: "missing", + description: "No folder yet. Pick the repository you want to work in.", + dotClassName: PROVIDER_STATUS_STYLES.warning.dot, + actionLabel: "Choose a folder", + }; + } + + const cwd = input.projectCwd?.trim(); + return { + state: "ready", + description: input.isOnlyWorkspaceProject + ? `${name} · the folder you launched from` + : cwd + ? `${name} · ${cwd}` + : name, + dotClassName: PROVIDER_STATUS_STYLES.ready.dot, + actionLabel: "Change", + }; +} + +/** + * "Start first thread" needs a provider that can actually serve a turn and a + * folder to serve it in. Anything less would hand the user straight back to + * the composer notice they were trying to get past. + */ +export function canStartFirstThread(input: { + readonly providerRows: ReadonlyArray; + readonly projectRow: FirstRunProjectRow; +}): boolean { + return ( + input.projectRow.state === "ready" && input.providerRows.some((row) => row.state === "ready") + ); +} From 826802bf74b9fb812f01c439661d2771df26e61a Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:30:05 -0400 Subject: [PATCH 2/5] Show each provider's target version in the multi-update card The updates-available card said only which providers can be updated; the single-provider variant already named its version in the title. The multi-provider description now reads like Codex v1.1.0 and Claude v2.1.197 can be updated, same one-line height. --- ...iderUpdateLaunchNotification.logic.test.ts | 6 ++++-- .../ProviderUpdateLaunchNotification.logic.ts | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 1b79926f..7eaa2784 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -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.", }); }); @@ -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.`, }; } From 681e0180a651be24950480fc5c491a899389e8a3 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:08:25 -0400 Subject: [PATCH 3/5] Fix the reloaded setup card, brand it, and show the project favicon Screenshot verification caught the real bug: a reloaded draft thread has no project bound yet, so the card claimed "No folder yet" while the sidebar showed the bootstrapped workspace project. The card now falls back to the workspace project list (and the stale memo deps that would have hidden the fix are corrected). Also from maintainer review of the gallery: - The card inherits the empty state's identity: the Threadlines figure above the heading and the same staged rise animation - The project row shows the project's favicon like every other project selector --- apps/web/src/components/ChatView.tsx | 23 +++++++---- .../chat/FirstRunSetupCard.browser.tsx | 1 + .../src/components/chat/FirstRunSetupCard.tsx | 38 +++++++++++++++---- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44b50b2d..342d542b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3389,10 +3389,16 @@ export default function ChatView(props: ChatViewProps) { hasUserMessagedThread, isDismissed: isFirstRunSetupDismissed, }); - const workspaceProjectCount = useMemo( - () => allProjects.filter((project) => project.kind !== "general-chat").length, + 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; @@ -3400,9 +3406,10 @@ export default function ChatView(props: ChatViewProps) { return ( { if (!row.signInCommand) return; void runProviderAuthReconnect({ @@ -3420,14 +3427,14 @@ export default function ChatView(props: ChatViewProps) { /> ); }, [ - activeProject?.cwd, - activeProject?.name, dismissFirstRunSetupForEnvironment, + environmentId, + firstRunProject, + firstRunWorkspaceProjects.length, providerInstanceEntries, runProviderAuthReconnect, scheduleComposerFocus, showFirstRunSetupCard, - workspaceProjectCount, ]); const runMcpAuthReconnect = useCallback( diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index 1776496c..a5f33ef0 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -96,6 +96,7 @@ function renderCard(props: { providers={props.providers} projectName={props.projectName} projectCwd={props.projectName === null ? null : "C:/code/B-git-project"} + projectEnvironmentId={null} isOnlyWorkspaceProject onSignIn={props.onSignIn ?? vi.fn()} onChooseProject={props.onChooseProject ?? vi.fn()} diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index 7a9ae34f..893bd390 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -18,6 +18,8 @@ import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { cn } from "../../lib/utils"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { Button } from "../ui/button"; import { buildFirstRunSetupDismissalKey, @@ -81,7 +83,7 @@ function SetupRow({ dotClassName: string; name: string; versionLabel?: string | null; - description: string; + description: ReactNode; action: ReactNode; }) { return ( @@ -145,6 +147,8 @@ export interface FirstRunSetupCardProps { readonly providers: ReadonlyArray; readonly projectName: string | null; readonly projectCwd: string | null; + /** Where the project lives; lets the row show its favicon like every other project selector. */ + readonly projectEnvironmentId: EnvironmentId | null; /** True when this is the workspace's only project, i.e. the launch folder. */ readonly isOnlyWorkspaceProject: boolean; readonly onSignIn: (row: FirstRunProviderRow) => void; @@ -157,6 +161,7 @@ export function FirstRunSetupCard({ providers, projectName, projectCwd, + projectEnvironmentId, isOnlyWorkspaceProject, onSignIn, onChooseProject, @@ -169,17 +174,33 @@ export function FirstRunSetupCard({ [isOnlyWorkspaceProject, projectCwd, projectName], ); const canStart = canStartFirstThread({ providerRows, projectRow }); + const projectDescription: ReactNode = + projectRow.state === "ready" && projectCwd && projectEnvironmentId ? ( + + + {projectRow.description} + + ) : ( + projectRow.description + ); return ( -
    -

    +
    + +

    Set up Threadlines

    -

    +

    Connect a coding agent and pick a folder. Rows update live as you go.

    -
      +
        {providerRows.map((row) => (
      -
      +
      You can start once one agent is signed in. From 7262dfe21410d5781b45d90e208417692b97d879 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:22:10 -0400 Subject: [PATCH 4/5] Keep setup card row descriptions to the diagnosis sentence Provider status details are written for the settings page and can run to a paragraph with install URLs; the Claude row rendered four lines. The card keeps the headline plus the first sentence; the full recipe stays one click away behind the row's action. --- apps/web/src/components/chat/firstRunSetup.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/firstRunSetup.ts b/apps/web/src/components/chat/firstRunSetup.ts index 85166c5f..4861c76b 100644 --- a/apps/web/src/components/chat/firstRunSetup.ts +++ b/apps/web/src/components/chat/firstRunSetup.ts @@ -191,7 +191,7 @@ function providerRowDescription( } const summary = getProviderSummary(provider.snapshot); - const detail = summary.detail?.trim(); + const detail = firstSentence(summary.detail); if (detail) { return `${summary.headline}. ${detail}`; } @@ -200,6 +200,21 @@ function providerRowDescription( : `${summary.headline}. Sign in to use ${provider.displayName} here.`; } +/** + * Row descriptions stay at roughly two rendered lines (design system), but + * provider status details are written for the settings page and can run to a + * paragraph with install URLs. The card keeps the diagnosis sentence; the + * full recipe is one click away behind the row's action. + */ +function firstSentence(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const match = /^.*?\.(?=\s|$)/.exec(trimmed); + return match ? match[0] : trimmed; +} + /** * One row per enabled provider instance, in the order the caller supplied * (settings order). Disabled instances are left out: the user turned them off, From b19cb67bc3b919190766c383f168a32dc760ed2e Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:26:34 -0400 Subject: [PATCH 5/5] Scale the setup card: compact figure, priority rows, provider cap Maintainer feedback on the verified screenshots: the full-size figure pushed the card down until the footer crowded the composer, and a row-per-provider list cannot grow with future providers. - The card uses a two-thirds figure with tighter margins, slimmer row padding, and guaranteed clearance above the composer - Provider rows order by actionability (sign-in, install, ready) instead of settings order; a checklist leads with the shortest path to one working agent, which also puts bundled Codex first on cold installs - The card caps at three provider rows; the rest collapse into a single More agents row pointing at Settings, so four or more providers never stretch the checklist --- apps/web/src/components/ThreadlinesFigure.tsx | 22 ++++++-- .../src/components/chat/FirstRunSetupCard.tsx | 36 +++++++++++-- .../src/components/chat/firstRunSetup.test.ts | 29 +++++++++++ apps/web/src/components/chat/firstRunSetup.ts | 50 +++++++++++++++++-- 4 files changed, 125 insertions(+), 12 deletions(-) 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 ( -