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
250 changes: 130 additions & 120 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,97 +129,151 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
updateSettings({ defaultModelSelection: value });
};

const modelRow = (
<SettingsRow
serverScoped
settingKeys={["defaultModelSelection"]}
mixed={mixedModel}
id="default-model"
title="Model"
description={
isProjectScope
? "Model for new threads in this project."
: "Default model for new threads. Projects can override it."
}
status={
unavailable || mixedModel || modelSource === "project"
? undefined
: settings.defaultModelSelection === null
? "Automatic"
: undefined
}
resetAction={
settings.defaultModelSelection !== null ? (
<SettingResetButton label="default model" onClick={() => setModel(null)} />
) : null
}
control={
selection && activeEntry ? (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<ProviderModelPicker
activeInstanceId={selection.instanceId}
model={selection.model}
lockedProvider={null}
instanceEntries={entries}
modelOptionsByInstance={modelOptions}
triggerVariant="outline"
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
{...(mixedModel ? { triggerLabel: "Mixed" } : {})}
getModelDisabledReason={modelDisabledReason}
onOpenProviderSetup={(instanceId) => {
if (representative)
void navigate({
to: "/settings/providers",
search: { environmentId: representative.environmentId, instanceId },
});
}}
onInstanceModelChange={(instanceId, model) =>
setModel(createModelSelection(instanceId, model))
}
/>
{!mixedModel ? (
<TraitsPicker
provider={activeEntry.driverKind}
models={activeEntry.models}
model={selection.model}
prompt=""
onPromptChange={() => {}}
modelOptions={selection.options ?? []}
allowPromptInjectedEffort={false}
planModeEnabled={settings.planModeEnabled}
triggerVariant="outline"
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onModelOptionsChange={(options) =>
setModel(createModelSelection(selection.instanceId, selection.model, options))
}
/>
) : null}
</div>
) : (
<span className="text-sm text-muted-foreground">No providers available</span>
)
}
/>
);
const workspaceRow = (
<SettingsRow
serverScoped
settingKeys={["defaultThreadEnvMode"]}
mixed={mixedWorkspace}
id={searchableSetting("new-threads").id}
title="Workspace"
description={
isProjectScope
? "Where new threads in this project start."
: "Where new threads start. Projects and their t3.json can override it."
}
resetAction={
!isProjectScope && settings.defaultThreadEnvMode !== null ? (
<SettingResetButton
label="default workspace"
onClick={() => updateSettings({ defaultThreadEnvMode: null })}
/>
) : null
}
control={
<Select
value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
onValueChange={(value) => {
if (value === "local" || value === "worktree")
updateSettings({ defaultThreadEnvMode: value });
}}
>
<SelectTrigger size="sm" aria-label="Default workspace">
<SelectValue>
{(value: string | null) =>
value === "local" || value === "worktree"
? resolveEnvModeLabel(value)
: unavailable
? "Unavailable"
: "Mixed"
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
<SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
</SelectPopup>
</Select>
}
/>
);

return (
<SettingsSection
id={
category === "general"
category === "general" || category === "project"
? "project-defaults"
: category === "integrations"
? "browser-access"
: "source-control-defaults"
}
title={
category === "general"
category === "general" || category === "project"
? "New threads"
: category === "integrations"
? "Browser"
: "Repositories"
}
>
{category === "general" ? (
{category === "project" ? (
<>
<SettingsRow
serverScoped
settingKeys={["defaultModelSelection"]}
mixed={mixedModel}
id="default-model"
title="Model"
description={
isProjectScope
? "Model for new threads in this project."
: "Default model for new threads. Projects can override it."
}
status={
unavailable || mixedModel || modelSource === "project"
? undefined
: settings.defaultModelSelection === null
? "Automatic"
: undefined
}
resetAction={
settings.defaultModelSelection !== null ? (
<SettingResetButton label="default model" onClick={() => setModel(null)} />
) : null
}
control={
selection && activeEntry ? (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<ProviderModelPicker
activeInstanceId={selection.instanceId}
model={selection.model}
lockedProvider={null}
instanceEntries={entries}
modelOptionsByInstance={modelOptions}
triggerVariant="outline"
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
{...(mixedModel ? { triggerLabel: "Mixed" } : {})}
getModelDisabledReason={modelDisabledReason}
onOpenProviderSetup={(instanceId) => {
if (representative)
void navigate({
to: "/settings/providers",
search: { environmentId: representative.environmentId, instanceId },
});
}}
onInstanceModelChange={(instanceId, model) =>
setModel(createModelSelection(instanceId, model))
}
/>
{!mixedModel ? (
<TraitsPicker
provider={activeEntry.driverKind}
models={activeEntry.models}
model={selection.model}
prompt=""
onPromptChange={() => {}}
modelOptions={selection.options ?? []}
allowPromptInjectedEffort={false}
planModeEnabled={settings.planModeEnabled}
triggerVariant="outline"
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onModelOptionsChange={(options) =>
setModel(
createModelSelection(selection.instanceId, selection.model, options),
)
}
/>
) : null}
</div>
) : (
<span className="text-sm text-muted-foreground">No providers available</span>
)
}
/>
{modelRow}
{workspaceRow}
</>
) : category === "general" ? (
<>
{modelRow}
<SettingsRow
serverScoped
settingKeys={["defaultRuntimeMode"]}
Expand Down Expand Up @@ -281,51 +335,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
</Select>
}
/>
<SettingsRow
serverScoped
settingKeys={["defaultThreadEnvMode"]}
mixed={mixedWorkspace}
id={searchableSetting("new-threads").id}
title="Workspace"
description={
isProjectScope
? "Where new threads in this project start."
: "Where new threads start. Projects and their t3.json can override it."
}
resetAction={
!isProjectScope && settings.defaultThreadEnvMode !== null ? (
<SettingResetButton
label="default workspace"
onClick={() => updateSettings({ defaultThreadEnvMode: null })}
/>
) : null
}
control={
<Select
value={mixedWorkspace ? null : (effective?.defaultThreadEnvMode ?? null)}
onValueChange={(value) => {
if (value === "local" || value === "worktree")
updateSettings({ defaultThreadEnvMode: value });
}}
>
<SelectTrigger size="sm" aria-label="Default workspace">
<SelectValue>
{(value: string | null) =>
value === "local" || value === "worktree"
? resolveEnvModeLabel(value)
: unavailable
? "Unavailable"
: "Mixed"
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
<SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
</SelectPopup>
</Select>
}
/>
{workspaceRow}
<SettingsRow
serverScoped
settingKeys={["worktreeSubmodules"]}
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import * as Cause from "effect/Cause";
import { Trash2Icon } from "lucide-react";
import { InfoIcon, Trash2Icon } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";

import { useComposerDraftStore } from "../../composerDraftStore";
Expand All @@ -25,6 +25,7 @@ import { useThreadShells } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
import { useAtomCommand } from "../../state/use-atom-command";
import { ProjectFavicon } from "../ProjectFavicon";
import { Alert, AlertDescription } from "../ui/alert";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { stackedThreadToast, toastManager } from "../ui/toast";
Expand All @@ -39,6 +40,7 @@ import {
ProjectFaviconPickerDialog,
} from "./ProjectFaviconPickerDialog";
import { ProjectActionsSettings } from "./ProjectActionsSettings";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";

Expand All @@ -52,7 +54,8 @@ function memberKey(member: { environmentId: string; id: string }): string {
return `${member.environmentId}:${member.id}`;
}

export type ProjectSettingsCategory = "general" | "integrations" | "source-control";
/** `project` is the Projects page shortcut: the new-thread defaults people change most. */
export type ProjectSettingsCategory = "general" | "integrations" | "source-control" | "project";

export function ProjectSettingsPanel({
projectKey,
Expand Down Expand Up @@ -406,6 +409,12 @@ function ProjectDetail({
return (
<>
<SettingsPageContainer className="gap-6">
<Alert variant="info">
<InfoIcon aria-hidden />
<AlertDescription>
Can't find a setting? Keep this project picked above and hop to any other settings page.
</AlertDescription>
</Alert>
<SettingsSection id="project-overview" title="Project" hideTitle>
<SettingsRow
title="Name"
Expand Down Expand Up @@ -480,6 +489,7 @@ function ProjectDetail({
}
/>
</SettingsSection>
<ProjectDefaultsSettings category="project" />
<ProjectActionsSettings />
{hasMultipleCheckouts ? checkoutChoices : null}
<SettingsSection title="Danger">
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/settings/ProjectsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts";
import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
import { useSettingsScope } from "./SettingsScopeContext";
import { SettingsScopeNotice } from "./SettingsScopeNotice";
import { SettingsPageContainer } from "./settingsLayout";

/** Project identity and checkout management for the selected project. */
export function ProjectsSettings() {
Expand All @@ -22,7 +23,9 @@ export function ProjectsSettings() {
checkoutKey={value.checkout ?? null}
/>
) : scope.kind === "unavailable" ? (
<p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
<SettingsPageContainer>
<p className="text-sm text-muted-foreground">{scope.message}</p>
</SettingsPageContainer>
) : (
<SettingsScopeNotice target="project">
Choose a project to manage its name, icon, checkouts and actions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ vi.mock("./settingsLayout", async (importOriginal) => {
};
});

vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
vi.mock("react/compiler-runtime", async () => {
const { reactHookHarness } = await import("../../test/reactHookHarness");
return { c: reactHookHarness.useMemoCache };
Expand Down
Loading
Loading