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
5 changes: 5 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@
Effect.gen(function* () {
const { join } = yield* Path.Path;
const baseDir = join(NodeOS.tmpdir(), "threadlines-cli-config-loopback-base");
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);

Check warning on line 710 in apps/server/src/cli/config.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Variable 'derivedPaths' is declared but never used. Unused variables should start with a '_'.

const resolved = yield* resolveServerConfig(
{
Expand Down Expand Up @@ -785,6 +785,11 @@

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);
}),
);
});
25 changes: 8 additions & 17 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,14 @@ const aliasedConfigWithDefault = <A>(
);

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"),
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -214,9 +214,14 @@ export const ChatHeader = memo(function ChatHeader({
</button>
) : null}
{activeProjectName && !isGitRepo && sourceControlAvailable && (
<Badge variant="outline" className="shrink-0 text-[10px] leading-none text-amber-700/90">
No Git
</Badge>
<TooltipWrapper tooltip="This folder isn't a git repository. Chat works; source control, diffs, and refs need git. Run git init to enable them.">
<Badge
variant="outline"
className="shrink-0 text-[10px] leading-none text-amber-700/90"
>
No Git
</Badge>
</TooltipWrapper>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3">
Expand Down
40 changes: 32 additions & 8 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"}`;

Expand Down Expand Up @@ -863,8 +874,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
})()
: orderedModels.map((model, modelIndex) => renderModelRow(model, modelIndex))}
</ComboboxListVirtualized>
<ComboboxEmpty className="not-empty:flex not-empty:flex-1 not-empty:items-center not-empty:justify-center not-empty:p-6 empty:h-0 text-xs font-normal leading-snug">
{emptyMessage}
<ComboboxEmpty className="not-empty:flex not-empty:flex-1 not-empty:flex-col not-empty:items-center not-empty:justify-center not-empty:gap-1.5 not-empty:p-6 empty:h-0 text-xs font-normal leading-snug">
{emptyState.lines.map((line) => (
<span key={line} className="max-w-72 text-balance">
{line}
</span>
))}
{emptyState.showSettingsAction ? (
<Link
to="/settings/providers"
className="cursor-pointer font-medium text-foreground underline-offset-2 transition-colors hover:underline focus-ring"
onClick={() => props.onRequestClose?.()}
>
Open Settings
</Link>
) : null}
</ComboboxEmpty>
</div>
</div>
Expand Down
106 changes: 104 additions & 2 deletions apps/web/src/components/chat/ProviderModelPicker.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(<RouterProvider router={router} />, { container });
}

async function mountPicker(props: {
activeInstanceId?: ProviderInstanceId;
model: string;
Expand All @@ -261,7 +289,7 @@ async function mountPicker(props: {
activeInstanceId,
props.model,
);
const screen = await render(
const screen = await renderWithTestRouter(
<ProviderModelPicker
activeInstanceId={activeInstanceId}
model={props.model}
Expand All @@ -272,7 +300,7 @@ async function mountPicker(props: {
triggerVariant={props.triggerVariant}
onInstanceModelChange={onInstanceModelChange}
/>,
{ container: host },
host,
);

return {
Expand Down Expand Up @@ -1519,6 +1547,80 @@ describe("ProviderModelPicker", () => {
}
});

it("explains an uninstalled provider instead of a bare no-match message", async () => {
const providers: ReadonlyArray<ServerProvider> = [
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<HTMLAnchorElement>('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<ServerProvider> = [
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",
Expand Down
Loading
Loading