From 1ceba210207d644a6c9d5c0a561eae362fc17cba Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:31:01 -0400 Subject: [PATCH] Guide new users at the model picker, No Git badge, and version env First batch of fresh-install quick wins from the 0.3.2 UX audit: - Model picker empty states name the provider and the fix (not installed vs needs sign-in) instead of the bare no-models-match text, with an Open Settings action, reusing the settings page status language - The picker no longer opens on an empty Favorites tab - The No Git badge explains what is disabled and how to enable it - Drop the generic APP_VERSION env alias; only namespaced version vars are honored so ambient variables cannot make the server misreport --- apps/server/src/cli/config.test.ts | 5 + apps/server/src/cli/config.ts | 25 ++-- apps/web/src/components/chat/ChatHeader.tsx | 13 +- .../components/chat/ModelPickerContent.tsx | 40 ++++-- .../chat/ProviderModelPicker.browser.tsx | 106 ++++++++++++++- .../components/chat/modelPickerEmptyState.ts | 128 ++++++++++++++++++ .../components/chat/modelPickerSearch.test.ts | 127 +++++++++++++++++ 7 files changed, 413 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/chat/modelPickerEmptyState.ts diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 0850a8a11..d51ca9583 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -785,6 +785,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { const productionServer = yield* resolveWith({}); expect(productionServer.appVersion).toBe(packageJson.version); + + // A bare APP_VERSION belongs to whatever tool happens to set it, so the + // server must ignore it rather than report someone else's version. + const genericVersion = yield* resolveWith({ APP_VERSION: "6.6.6" }); + expect(genericVersion.appVersion).toBe(packageJson.version); }), ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 73269c7ae..ea7aa12e9 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -113,23 +113,14 @@ const aliasedConfigWithDefault = ( ); const EnvServerConfig = Config.all({ - appVersion: Config.all({ - threadlines: Config.nonEmptyString("THREADLINES_APP_VERSION").pipe(Config.option), - badcode: Config.nonEmptyString("BADCODE_APP_VERSION").pipe(Config.option), - legacyT3Code: Config.nonEmptyString("T3CODE_APP_VERSION").pipe(Config.option), - generic: Config.nonEmptyString("APP_VERSION").pipe(Config.option), - }).pipe( - Config.map(({ threadlines, badcode, legacyT3Code, generic }) => - Option.getOrUndefined( - Option.isSome(threadlines) - ? threadlines - : Option.isSome(badcode) - ? badcode - : Option.isSome(legacyT3Code) - ? legacyT3Code - : generic, - ), - ), + // Only the namespaced aliases are honored. A bare `APP_VERSION` is a + // common ambient variable that any unrelated tool can set, and trusting + // it made the server misreport its own version; the desktop shell always + // sets `THREADLINES_APP_VERSION` alongside it. + appVersion: aliasedConfigOption( + Config.nonEmptyString("THREADLINES_APP_VERSION"), + Config.nonEmptyString("BADCODE_APP_VERSION"), + Config.nonEmptyString("T3CODE_APP_VERSION"), ), logLevel: aliasedConfigWithDefault( Config.logLevel("THREADLINES_LOG_LEVEL"), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 4a68cddb0..2deeae0ca 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,7 +16,7 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Group } from "../ui/group"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipTrigger, TooltipWrapper } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarOpenTrigger } from "../ui/sidebar"; @@ -214,9 +214,14 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} {activeProjectName && !isGitRepo && sourceControlAvailable && ( - - No Git - + + + No Git + + )}
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7f8c3470a..669a4ea07 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -14,9 +14,11 @@ import { useLayoutEffect, useRef, } from "react"; +import { Link } from "@tanstack/react-router"; import { SearchIcon, StarIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { ModelListRow } from "./ModelListRow"; +import { resolveModelPickerEmptyState } from "./modelPickerEmptyState"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox"; import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; @@ -404,7 +406,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ]; }, [favoriteModels, isLocked, providerTabs, showTabList]); - const defaultActiveTabId = activeModelIsFavorite ? "favorites" : props.activeInstanceId; + // Favorites only earns the opening tab when it has something to show. A + // fresh install has no favorites (and a stale favorite can point at an + // instance that is no longer ready), so falling through to the provider + // tab keeps the picker from opening on an empty pane. + const defaultActiveTabId = + activeModelIsFavorite && favoriteModels.length > 0 ? "favorites" : props.activeInstanceId; const activeTab = useMemo(() => { if (!showTabList) { return null; @@ -471,11 +478,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [activeTabModels, isSearching, searchGroups], ); - const emptyMessage = isSearching - ? "No models match" - : activeTab?.kind === "favorites" - ? "No favorite models" - : "No models available"; + const emptyState = useMemo( + () => + resolveModelPickerEmptyState({ + searchQuery: normalizedSearchQuery, + activeTabKind: activeTab?.kind ?? null, + providers: instanceEntries, + }), + [activeTab?.kind, instanceEntries, normalizedSearchQuery], + ); const tabModelCountLabel = (count: number) => `${count} ${count === 1 ? "model" : "models"}`; @@ -863,8 +874,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { })() : orderedModels.map((model, modelIndex) => renderModelRow(model, modelIndex))} - - {emptyMessage} + + {emptyState.lines.map((line) => ( + + {line} + + ))} + {emptyState.showSettingsAction ? ( + props.onRequestClose?.()} + > + Open Settings + + ) : null}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 776f7c0dc..537f9c029 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -5,6 +5,14 @@ import { } from "@threadlines/contracts"; import { EnvironmentId } from "@threadlines/contracts"; import { createModelCapabilities } from "@threadlines/shared/model"; +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import type { ReactNode } from "react"; import { page, userEvent } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; @@ -238,6 +246,26 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { }; } +/** + * The picker's empty state links to provider settings, so the component + * needs a router in context. Mount every case through the same throwaway + * memory router rather than special-casing one test. + */ +function renderWithTestRouter(children: ReactNode, container: HTMLElement) { + const rootRoute = createRootRoute({ component: () => children }); + 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(, { container }); +} + async function mountPicker(props: { activeInstanceId?: ProviderInstanceId; model: string; @@ -261,7 +289,7 @@ async function mountPicker(props: { activeInstanceId, props.model, ); - const screen = await render( + const screen = await renderWithTestRouter( , - { container: host }, + host, ); return { @@ -1519,6 +1547,80 @@ describe("ProviderModelPicker", () => { } }); + it("explains an uninstalled provider instead of a bare no-match message", async () => { + const providers: ReadonlyArray = [ + TEST_PROVIDERS[0]!, + { + ...TEST_PROVIDERS[1]!, + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }, + ]; + + const mounted = await mountPicker({ + model: "gpt-5-codex", + lockedProvider: null, + providers, + }); + + try { + await openModelPicker(); + await fillModelPickerSearch("claude"); + + await vi.waitFor(() => { + expect(getModelPickerListText()).toContain( + "Claude isn't installed. Install it and sign in from Settings.", + ); + expect( + document.querySelector('a[href="/settings/providers"]'), + ).not.toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("opens a provider tab when a favorited model is no longer selectable", async () => { + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: "claudeAgent", model: "claude-opus-4-6" }], + }), + ); + const providers: ReadonlyArray = [ + TEST_PROVIDERS[0]!, + { + ...TEST_PROVIDERS[1]!, + installed: false, + status: "warning", + auth: { status: "unknown" }, + }, + ]; + + const mounted = await mountPicker({ + activeInstanceId: CLAUDE_INSTANCE_ID, + model: "claude-opus-4-6", + lockedProvider: null, + providers, + }); + + try { + await openModelPicker(); + + await vi.waitFor(() => { + expect(getModelPickerTabOrder()).toEqual(["favorites", "codex"]); + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex", "GPT-5.3 Codex"]); + expect(getModelPickerListText()).not.toContain("No favorite models"); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem(CLIENT_SETTINGS_STORAGE_KEY); + } + }); + it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ model: "gpt-5-codex", diff --git a/apps/web/src/components/chat/modelPickerEmptyState.ts b/apps/web/src/components/chat/modelPickerEmptyState.ts new file mode 100644 index 000000000..868fcd69a --- /dev/null +++ b/apps/web/src/components/chat/modelPickerEmptyState.ts @@ -0,0 +1,128 @@ +/** + * Empty-state copy for the model picker. + * + * A fresh install has providers configured but not installed or not signed + * in, so the picker's list is empty for a reason the user can act on. These + * helpers turn the provider snapshots the client already holds into the + * lines shown in place of the bare "No models match" / "No models + * available" text, reusing the settings page's status language + * (`getProviderSummary`) so both surfaces describe a provider the same way. + * + * @module modelPickerEmptyState + */ +import type { ServerProvider } from "@threadlines/contracts"; + +import { getProviderSummary } from "../settings/providerStatus"; + +/** Why a configured provider cannot currently serve models. */ +export type ModelPickerProviderAvailability = "available" | "notInstalled" | "notAuthenticated"; + +/** + * The slice of a `ProviderInstanceEntry` the empty state needs. Declared + * structurally so entries pass through unchanged and tests can build a + * minimal fixture. + */ +export interface ModelPickerProviderState { + readonly displayName: string; + readonly driverKind: string; + readonly enabled: boolean; + readonly snapshot: ServerProvider; +} + +export interface ModelPickerEmptyState { + /** One line per rendered message; always at least one entry. */ + readonly lines: ReadonlyArray; + /** + * True when the lines point at something the user fixes in provider + * settings, so the picker should offer the "Open Settings" action. + */ + readonly showSettingsAction: boolean; +} + +/** + * `unknown` auth is treated as available: the server has not contradicted + * the credential, and nagging about a provider we cannot judge is worse + * than saying nothing. + */ +export function getModelPickerProviderAvailability( + provider: ServerProvider, +): ModelPickerProviderAvailability { + if (!provider.installed) { + return "notInstalled"; + } + const chatCapability = provider.auth.capabilities?.chat?.status; + if (chatCapability === "verified" || chatCapability === "configured") { + return "available"; + } + if (chatCapability === "unavailable" || provider.auth.status === "unauthenticated") { + return "notAuthenticated"; + } + return "available"; +} + +/** + * Loose match between a search query and a provider: the query is a + * case-insensitive substring of either the driver kind (`claudeAgent`) or + * the instance display name ("Claude Personal"). Typing "claude" when + * Claude is missing should explain the absence rather than say nothing. + */ +function matchesProviderQuery(provider: ModelPickerProviderState, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) { + return false; + } + return ( + provider.driverKind.toLowerCase().includes(normalizedQuery) || + provider.displayName.toLowerCase().includes(normalizedQuery) + ); +} + +function unavailableProviderLine(provider: ModelPickerProviderState): string { + return getModelPickerProviderAvailability(provider.snapshot) === "notInstalled" + ? `${provider.displayName} isn't installed. Install it and sign in from Settings.` + : `${provider.displayName} needs sign-in. Connect it from Settings.`; +} + +export function resolveModelPickerEmptyState(input: { + /** Raw search box contents; blank means the user is browsing tabs. */ + readonly searchQuery: string; + readonly activeTabKind: "favorites" | "instance" | null; + readonly providers: ReadonlyArray; +}): ModelPickerEmptyState { + const query = input.searchQuery.trim(); + if (query.length > 0) { + const unavailableMatches = input.providers.filter( + (provider) => + matchesProviderQuery(provider, query) && + getModelPickerProviderAvailability(provider.snapshot) !== "available", + ); + if (unavailableMatches.length === 0) { + return { lines: ["No models match"], showSettingsAction: false }; + } + return { + lines: unavailableMatches.map(unavailableProviderLine), + showSettingsAction: true, + }; + } + + if (input.activeTabKind === "favorites") { + return { lines: ["No favorite models"], showSettingsAction: false }; + } + + // Nothing to pick and every provider the user enabled is unusable: name + // each one instead of leaving them to guess which is broken. + const enabledProviders = input.providers.filter((provider) => provider.enabled); + const unavailableProviders = enabledProviders.filter( + (provider) => getModelPickerProviderAvailability(provider.snapshot) !== "available", + ); + if (enabledProviders.length > 0 && unavailableProviders.length === enabledProviders.length) { + return { + lines: unavailableProviders.map( + (provider) => `${provider.displayName} · ${getProviderSummary(provider.snapshot).headline}`, + ), + showSettingsAction: true, + }; + } + + return { lines: ["No models available"], showSettingsAction: false }; +} diff --git a/apps/web/src/components/chat/modelPickerSearch.test.ts b/apps/web/src/components/chat/modelPickerSearch.test.ts index e1e306228..b44bb0512 100644 --- a/apps/web/src/components/chat/modelPickerSearch.test.ts +++ b/apps/web/src/components/chat/modelPickerSearch.test.ts @@ -1,6 +1,15 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@threadlines/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + resolveModelPickerEmptyState, + type ModelPickerProviderState, +} from "./modelPickerEmptyState"; describe("buildModelPickerSearchText", () => { it("builds provider-agnostic search text from generic fields", () => { @@ -129,3 +138,121 @@ describe("scoreModelPickerSearch", () => { ).not.toBeNull(); }); }); + +describe("resolveModelPickerEmptyState", () => { + const makeProvider = ( + driverKind: string, + displayName: string, + overrides: Partial = {}, + ): ModelPickerProviderState => { + const snapshot = { + driver: ProviderDriverKind.make(driverKind), + instanceId: ProviderInstanceId.make(driverKind), + displayName, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + } satisfies ServerProvider; + return { + displayName, + driverKind, + enabled: snapshot.enabled, + snapshot, + }; + }; + + const CODEX_READY = makeProvider("codex", "Codex"); + const CLAUDE_MISSING = makeProvider("claudeAgent", "Claude", { + installed: false, + status: "warning", + auth: { status: "unknown" }, + }); + const CLAUDE_SIGNED_OUT = makeProvider("claudeAgent", "Claude", { + status: "warning", + auth: { status: "unauthenticated" }, + }); + + it("explains an uninstalled provider the query names", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "Claude", + activeTabKind: "instance", + providers: [CODEX_READY, CLAUDE_MISSING], + }), + ).toEqual({ + lines: ["Claude isn't installed. Install it and sign in from Settings."], + showSettingsAction: true, + }); + }); + + it("explains a provider that is installed but signed out", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "claude", + activeTabKind: "instance", + providers: [CODEX_READY, CLAUDE_SIGNED_OUT], + }), + ).toEqual({ + lines: ["Claude needs sign-in. Connect it from Settings."], + showSettingsAction: true, + }); + }); + + it("keeps the plain no-match message when the query names no unusable provider", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "sonnet", + activeTabKind: "instance", + providers: [CODEX_READY, CLAUDE_MISSING], + }), + ).toEqual({ lines: ["No models match"], showSettingsAction: false }); + }); + + it("lists every provider when none of the enabled ones are usable", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "", + activeTabKind: "instance", + providers: [ + makeProvider("codex", "Codex", { + installed: false, + status: "warning", + auth: { status: "unknown" }, + message: "CLI not detected on PATH.", + }), + CLAUDE_SIGNED_OUT, + ], + }), + ).toEqual({ + lines: ["Codex · Not found", "Claude · Not authenticated"], + showSettingsAction: true, + }); + }); + + it("stays generic while at least one enabled provider is usable", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "", + activeTabKind: "instance", + providers: [CODEX_READY, CLAUDE_MISSING], + }), + ).toEqual({ lines: ["No models available"], showSettingsAction: false }); + }); + + it("keeps the favorites tab message provider-agnostic", () => { + expect( + resolveModelPickerEmptyState({ + searchQuery: "", + activeTabKind: "favorites", + providers: [CLAUDE_MISSING], + }), + ).toEqual({ lines: ["No favorite models"], showSettingsAction: false }); + }); +});